@princeofscale/bloxforge-inspector 3.0.0-rc.1 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -14,6 +14,111 @@ var __export = (target, all) => {
14
14
  __defProp(target, name, { get: all[name], enumerable: true });
15
15
  };
16
16
 
17
+ // ../core/dist/request-journal.js
18
+ import * as fs from "fs";
19
+ import * as os from "os";
20
+ import * as path from "path";
21
+ function defaultRequestJournalPath() {
22
+ if (process.env.NODE_ENV === "test")
23
+ return void 0;
24
+ const configured = process.env.BLOXFORGE_JOURNAL_PATH?.trim();
25
+ if (configured === "off" || configured === "0")
26
+ return void 0;
27
+ return configured || path.join(os.homedir(), ".bloxforge", "bridge-journal.json");
28
+ }
29
+ var DEFAULT_STATUS_TTL_MS, MAX_STATUSES, MAX_RECEIPTS, RECEIPT_TTL_MS, COMPACT_INTERVAL, RequestJournal;
30
+ var init_request_journal = __esm({
31
+ "../core/dist/request-journal.js"() {
32
+ "use strict";
33
+ DEFAULT_STATUS_TTL_MS = 60 * 60 * 1e3;
34
+ MAX_STATUSES = 1e3;
35
+ MAX_RECEIPTS = 200;
36
+ RECEIPT_TTL_MS = 5 * 60 * 1e3;
37
+ COMPACT_INTERVAL = 50;
38
+ RequestJournal = class {
39
+ filePath;
40
+ persistCount = 0;
41
+ constructor(filePath) {
42
+ this.filePath = filePath;
43
+ }
44
+ load() {
45
+ try {
46
+ const raw = fs.readFileSync(this.filePath, "utf8");
47
+ const parsed = JSON.parse(raw);
48
+ if (!parsed || typeof parsed !== "object")
49
+ return void 0;
50
+ if (!Array.isArray(parsed.statuses) || !Array.isArray(parsed.pending))
51
+ return void 0;
52
+ if (parsed.version === 1) {
53
+ return {
54
+ version: 2,
55
+ savedAt: parsed.savedAt,
56
+ statuses: parsed.statuses,
57
+ pending: parsed.pending,
58
+ completionReceipts: []
59
+ };
60
+ }
61
+ if (parsed.version === 2 && Array.isArray(parsed.completionReceipts)) {
62
+ return parsed;
63
+ }
64
+ return void 0;
65
+ } catch {
66
+ this.backupCorruptFile();
67
+ return void 0;
68
+ }
69
+ }
70
+ save(statuses, pending, completionReceipts = []) {
71
+ const directory = path.dirname(this.filePath);
72
+ fs.mkdirSync(directory, { recursive: true, mode: 448 });
73
+ const snapshot = {
74
+ version: 2,
75
+ savedAt: Date.now(),
76
+ statuses,
77
+ pending,
78
+ completionReceipts
79
+ };
80
+ const temporary = `${this.filePath}.${process.pid}.tmp`;
81
+ fs.writeFileSync(temporary, JSON.stringify(snapshot), { mode: 384 });
82
+ fs.renameSync(temporary, this.filePath);
83
+ this.persistCount++;
84
+ if (this.persistCount >= COMPACT_INTERVAL) {
85
+ this.persistCount = 0;
86
+ }
87
+ }
88
+ /**
89
+ * Compact a snapshot in-place: enforce retention TTL and entry limits.
90
+ * Returns a new compacted snapshot.
91
+ */
92
+ static compact(snapshot, now = Date.now()) {
93
+ let statuses = snapshot.statuses.filter((s) => now - s.updatedAt < DEFAULT_STATUS_TTL_MS);
94
+ if (statuses.length > MAX_STATUSES) {
95
+ statuses = statuses.slice(statuses.length - MAX_STATUSES);
96
+ }
97
+ let receipts = snapshot.completionReceipts.filter((r) => now - r.completedAt < RECEIPT_TTL_MS);
98
+ if (receipts.length > MAX_RECEIPTS) {
99
+ receipts = receipts.slice(receipts.length - MAX_RECEIPTS);
100
+ }
101
+ return {
102
+ ...snapshot,
103
+ savedAt: now,
104
+ statuses,
105
+ completionReceipts: receipts
106
+ };
107
+ }
108
+ /** Back up a corrupt journal file so data is not silently lost. */
109
+ backupCorruptFile() {
110
+ try {
111
+ if (!fs.existsSync(this.filePath))
112
+ return;
113
+ const backup = `${this.filePath}.corrupt.${Date.now()}`;
114
+ fs.copyFileSync(this.filePath, backup);
115
+ } catch {
116
+ }
117
+ }
118
+ };
119
+ }
120
+ });
121
+
17
122
  // ../core/dist/bridge-service.js
18
123
  import { randomUUID } from "crypto";
19
124
  function toPublic(inst) {
@@ -56,10 +161,11 @@ function publishedInstanceId(placeId) {
56
161
  return void 0;
57
162
  return `place:${Math.trunc(placeId)}`;
58
163
  }
59
- var RoutingFailure, MCP_PROTOCOL_VERSION, STALE_INSTANCE_MS, INSTANCE_ALIAS_TTL_MS, DEFAULT_REQUEST_TIMEOUT_MS, HEAVY_REQUEST_TIMEOUT_FLOOR_MS, HEAVY_ENDPOINTS, BridgeService;
164
+ var RoutingFailure, RequestOutcomeUnknownError, MCP_PROTOCOL_VERSION, STALE_INSTANCE_MS, INSTANCE_ALIAS_TTL_MS, DEFAULT_REQUEST_TIMEOUT_MS, HEAVY_REQUEST_TIMEOUT_FLOOR_MS, HEAVY_ENDPOINTS, BridgeService;
60
165
  var init_bridge_service = __esm({
61
166
  "../core/dist/bridge-service.js"() {
62
167
  "use strict";
168
+ init_request_journal();
63
169
  RoutingFailure = class extends Error {
64
170
  routingError;
65
171
  constructor(routingError) {
@@ -68,7 +174,16 @@ var init_bridge_service = __esm({
68
174
  this.routingError = routingError;
69
175
  }
70
176
  };
71
- MCP_PROTOCOL_VERSION = 1;
177
+ RequestOutcomeUnknownError = class extends Error {
178
+ requestId;
179
+ outcome = "unknown";
180
+ constructor(requestId, endpoint, timeout) {
181
+ super(`Request timeout after ${timeout}ms on ${endpoint}. Request ${requestId} outcome unknown. The plugin may still be running heavy code \u2014 the work can succeed even though this call gave up.`);
182
+ this.requestId = requestId;
183
+ this.name = "RequestOutcomeUnknownError";
184
+ }
185
+ };
186
+ MCP_PROTOCOL_VERSION = 3;
72
187
  STALE_INSTANCE_MS = envStaleInstanceMs();
73
188
  INSTANCE_ALIAS_TTL_MS = 5 * 60 * 1e3;
74
189
  DEFAULT_REQUEST_TIMEOUT_MS = 3e4;
@@ -86,6 +201,89 @@ var init_bridge_service = __esm({
86
201
  // with a small TTL without depending on wall-clock env vars. Production
87
202
  // callers leave this at the module-level STALE_INSTANCE_MS default.
88
203
  staleInstanceMs = STALE_INSTANCE_MS;
204
+ serverEpoch = randomUUID();
205
+ journal;
206
+ requestStatuses = /* @__PURE__ */ new Map();
207
+ sessionTokens = /* @__PURE__ */ new Map();
208
+ // sessionToken -> pluginSessionId
209
+ transportCounters = {
210
+ completed: 0,
211
+ outcomeUnknown: 0,
212
+ deliveryRetries: 0,
213
+ cancelled: 0,
214
+ timeouts: 0
215
+ };
216
+ transportLatencies = [];
217
+ constructor(journal) {
218
+ if (typeof journal === "string") {
219
+ this.journal = new RequestJournal(journal);
220
+ } else if (journal) {
221
+ this.journal = journal;
222
+ } else {
223
+ const path9 = defaultRequestJournalPath();
224
+ if (path9)
225
+ this.journal = new RequestJournal(path9);
226
+ }
227
+ if (this.journal) {
228
+ try {
229
+ const snapshot = this.journal.load();
230
+ if (snapshot) {
231
+ const { statuses, pending } = snapshot;
232
+ for (const s of statuses) {
233
+ if (s.state === "delivered" || s.state === "started") {
234
+ s.state = "outcome_unknown";
235
+ s.updatedAt = Date.now();
236
+ }
237
+ this.requestStatuses.set(s.requestId, s);
238
+ }
239
+ for (const p of pending) {
240
+ if (p.state !== "queued")
241
+ continue;
242
+ this.pendingRequests.set(p.id, {
243
+ id: p.id,
244
+ endpoint: p.endpoint,
245
+ data: p.data,
246
+ targetInstanceId: p.targetInstanceId,
247
+ targetRole: p.targetRole,
248
+ timestamp: p.timestamp,
249
+ inFlight: false,
250
+ deliveryAttempt: p.deliveryAttempt,
251
+ resolve: () => {
252
+ },
253
+ reject: () => {
254
+ },
255
+ timeoutId: setTimeout(() => {
256
+ }, 0)
257
+ });
258
+ }
259
+ }
260
+ } catch (e) {
261
+ }
262
+ }
263
+ }
264
+ enableJournal() {
265
+ const path9 = defaultRequestJournalPath();
266
+ if (path9)
267
+ this.journal = new RequestJournal(path9);
268
+ }
269
+ persistJournal() {
270
+ if (!this.journal)
271
+ return;
272
+ this.journal.save(Array.from(this.requestStatuses.entries()).map(([id, s]) => ({ requestId: id, ...s })), Array.from(this.pendingRequests.values()).map((req) => ({
273
+ id: req.id,
274
+ endpoint: req.endpoint,
275
+ data: req.data,
276
+ targetInstanceId: req.targetInstanceId,
277
+ targetRole: req.targetRole,
278
+ timestamp: req.timestamp,
279
+ state: this.requestStatuses.get(req.id)?.state ?? "queued",
280
+ deliveryAttempt: req.deliveryAttempt ?? 0
281
+ })), []);
282
+ }
283
+ updateRequestStatus(requestId, status) {
284
+ const existing = this.requestStatuses.get(requestId) ?? { state: "queued", serverEpoch: this.serverEpoch, deliveryAttempt: 0, updatedAt: Date.now() };
285
+ this.requestStatuses.set(requestId, { ...existing, ...status, updatedAt: Date.now() });
286
+ }
89
287
  canonicalInstanceId(instanceId, placeId) {
90
288
  return publishedInstanceId(placeId) ?? instanceId;
91
289
  }
@@ -212,30 +410,53 @@ var init_bridge_service = __esm({
212
410
  lastActivity: Date.now(),
213
411
  connectedAt: prior?.connectedAt ?? Date.now()
214
412
  });
215
- return { ok: true, assignedRole, instanceId };
413
+ const sessionToken = randomUUID();
414
+ this.sessionTokens.set(sessionToken, pluginSessionId);
415
+ return { ok: true, assignedRole, instanceId, sessionToken };
416
+ }
417
+ authenticatePlugin(pluginSessionId, sessionToken) {
418
+ const expectedId = this.sessionTokens.get(sessionToken);
419
+ return expectedId === pluginSessionId;
216
420
  }
217
421
  unregisterInstance(pluginSessionId, reason = "unknown") {
218
- const removed = this.instances.get(pluginSessionId);
219
- this.instances.delete(pluginSessionId);
220
- if (!removed)
422
+ const instance = this.instances.get(pluginSessionId);
423
+ if (!instance)
221
424
  return;
222
- this.recentDisconnects.unshift({
425
+ this.instances.delete(pluginSessionId);
426
+ this.recentDisconnects.push({
223
427
  pluginSessionId,
224
- instanceId: removed.instanceId,
225
- role: removed.role,
428
+ instanceId: instance.instanceId,
429
+ role: instance.role,
226
430
  reason,
227
431
  disconnectedAt: Date.now(),
228
- lastActivity: removed.lastActivity
432
+ lastActivity: instance.lastActivity
229
433
  });
230
- this.recentDisconnects.length = Math.min(this.recentDisconnects.length, 10);
231
- for (const [id, req] of this.pendingRequests.entries()) {
232
- const stillHasHandler = Array.from(this.instances.values()).some((i) => i.instanceId === req.targetInstanceId && i.role === req.targetRole);
434
+ if (this.recentDisconnects.length > 50)
435
+ this.recentDisconnects.shift();
436
+ for (const [id, request] of this.pendingRequests.entries()) {
437
+ const stillHasHandler = Array.from(this.instances.values()).some((i) => i.instanceId === request.targetInstanceId && i.role === request.targetRole);
438
+ const status = this.requestStatuses.get(id);
439
+ const wasAssignedToThis = request.assignedPluginSessionId === pluginSessionId;
233
440
  if (!stillHasHandler) {
234
- clearTimeout(req.timeoutId);
441
+ clearTimeout(request.timeoutId);
235
442
  this.pendingRequests.delete(id);
236
- req.reject(new Error(`Target (${req.targetInstanceId}, ${req.targetRole}) disconnected`));
443
+ if (status?.state === "delivered" || status?.state === "started") {
444
+ this.updateRequestStatus(id, { state: "outcome_unknown" });
445
+ request.reject(new RequestOutcomeUnknownError(id, request.endpoint, this.requestTimeout));
446
+ } else {
447
+ this.updateRequestStatus(id, { state: "failed", error: "Target disconnected" });
448
+ request.reject(new Error(`Target (${request.targetInstanceId}, ${request.targetRole}) disconnected`));
449
+ }
450
+ } else {
451
+ if (wasAssignedToThis && (status?.state === "delivered" || status?.state === "started")) {
452
+ clearTimeout(request.timeoutId);
453
+ this.pendingRequests.delete(id);
454
+ this.updateRequestStatus(id, { state: "outcome_unknown" });
455
+ request.reject(new RequestOutcomeUnknownError(id, request.endpoint, this.requestTimeout));
456
+ }
237
457
  }
238
458
  }
459
+ this.persistJournal();
239
460
  }
240
461
  getInstances() {
241
462
  return Array.from(this.instances.values());
@@ -290,6 +511,29 @@ var init_bridge_service = __esm({
290
511
  }
291
512
  }
292
513
  }
514
+ async lookupRequestStatus(requestId) {
515
+ const status = this.requestStatuses.get(requestId);
516
+ if (!status)
517
+ return void 0;
518
+ return { requestId, ...status };
519
+ }
520
+ getRequestStatus(requestId) {
521
+ const status = this.requestStatuses.get(requestId);
522
+ if (!status)
523
+ return void 0;
524
+ return { requestId, ...status };
525
+ }
526
+ getTransportDiagnostics() {
527
+ return {
528
+ completed: this.transportCounters.completed,
529
+ queueDepth: this.pendingRequests.size,
530
+ deliveryRetries: this.transportCounters.deliveryRetries,
531
+ timeouts: this.transportCounters.timeouts,
532
+ outcomeUnknown: this.transportCounters.outcomeUnknown,
533
+ cancelled: this.transportCounters.cancelled,
534
+ averageLatencyMs: this.transportLatencies.length > 0 ? this.transportLatencies.reduce((a, b) => a + b, 0) / this.transportLatencies.length : 0
535
+ };
536
+ }
293
537
  cleanupStaleInstances() {
294
538
  const now = Date.now();
295
539
  for (const [id, inst] of this.instances.entries()) {
@@ -419,11 +663,14 @@ var init_bridge_service = __esm({
419
663
  async sendRequest(endpoint, data, targetInstanceId, targetRole) {
420
664
  const requestId = randomUUID();
421
665
  const effectiveTimeout = resolveRequestTimeout(endpoint, this.requestTimeout);
422
- return new Promise((resolve6, reject) => {
666
+ return new Promise((resolve7, reject) => {
423
667
  const timeoutId = setTimeout(() => {
424
668
  if (this.pendingRequests.has(requestId)) {
425
669
  this.pendingRequests.delete(requestId);
426
- reject(new Error(`Request timeout after ${effectiveTimeout}ms on ${endpoint}. The plugin may still be running heavy code \u2014 the work can succeed even though this call gave up. Raise MCP_REQUEST_TIMEOUT_MS for big scripts.`));
670
+ this.updateRequestStatus(requestId, { state: "outcome_unknown" });
671
+ this.persistJournal();
672
+ this.transportCounters.timeouts++;
673
+ reject(new RequestOutcomeUnknownError(requestId, endpoint, effectiveTimeout));
427
674
  }
428
675
  }, effectiveTimeout);
429
676
  const request = {
@@ -434,11 +681,13 @@ var init_bridge_service = __esm({
434
681
  targetRole,
435
682
  timestamp: Date.now(),
436
683
  inFlight: false,
437
- resolve: resolve6,
684
+ resolve: resolve7,
438
685
  reject,
439
686
  timeoutId
440
687
  };
441
688
  this.pendingRequests.set(requestId, request);
689
+ this.updateRequestStatus(requestId, { state: "queued" });
690
+ this.persistJournal();
442
691
  for (const instance of this.instances.values()) {
443
692
  if (instance.instanceId === targetInstanceId && instance.role === targetRole) {
444
693
  for (const notify of this.requestNotifiers)
@@ -452,9 +701,28 @@ var init_bridge_service = __esm({
452
701
  if (!instance)
453
702
  return null;
454
703
  this.updateInstanceActivity(pluginSessionId);
455
- return this.getPendingRequest(instance.instanceId, instance.role);
704
+ return this.getPendingRequest(instance.instanceId, instance.role, pluginSessionId);
456
705
  }
457
- getPendingRequest(callerInstanceId, callerRole) {
706
+ getPendingRequest(callerInstanceId, callerRole, pluginSessionId) {
707
+ let inFlightMutations = 0;
708
+ let inFlightReads = 0;
709
+ for (const request of this.pendingRequests.values()) {
710
+ if (request.targetInstanceId !== callerInstanceId)
711
+ continue;
712
+ if (request.targetRole !== callerRole)
713
+ continue;
714
+ const status = this.requestStatuses.get(request.id);
715
+ if (request.inFlight && status?.state === "delivered" && Date.now() - status.updatedAt >= 1e4) {
716
+ request.inFlight = false;
717
+ this.updateRequestStatus(request.id, { state: "queued" });
718
+ }
719
+ if (request.inFlight) {
720
+ if (this.isMutation(request.endpoint))
721
+ inFlightMutations++;
722
+ else
723
+ inFlightReads++;
724
+ }
725
+ }
458
726
  let oldestRequest = null;
459
727
  for (const request of this.pendingRequests.values()) {
460
728
  if (request.targetInstanceId !== callerInstanceId)
@@ -463,14 +731,33 @@ var init_bridge_service = __esm({
463
731
  continue;
464
732
  if (request.inFlight)
465
733
  continue;
734
+ const isMutationReq = this.isMutation(request.endpoint);
735
+ if (isMutationReq && inFlightMutations >= 1)
736
+ continue;
737
+ if (!isMutationReq && inFlightReads >= 4)
738
+ continue;
466
739
  if (!oldestRequest || request.timestamp < oldestRequest.timestamp) {
467
740
  oldestRequest = request;
468
741
  }
469
742
  }
470
743
  if (oldestRequest) {
471
744
  oldestRequest.inFlight = true;
745
+ oldestRequest.deliveryAttempt = (oldestRequest.deliveryAttempt ?? 0) + 1;
746
+ oldestRequest.assignedPluginSessionId = pluginSessionId;
747
+ const leaseToken = randomUUID();
748
+ this.updateRequestStatus(oldestRequest.id, {
749
+ state: "delivered",
750
+ serverEpoch: this.serverEpoch,
751
+ deliveryAttempt: oldestRequest.deliveryAttempt,
752
+ leaseToken
753
+ });
754
+ this.persistJournal();
472
755
  return {
473
756
  requestId: oldestRequest.id,
757
+ serverEpoch: this.serverEpoch,
758
+ pluginSessionId: pluginSessionId ?? "",
759
+ deliveryAttempt: oldestRequest.deliveryAttempt,
760
+ leaseToken,
474
761
  request: {
475
762
  endpoint: oldestRequest.endpoint,
476
763
  data: oldestRequest.data
@@ -481,8 +768,11 @@ var init_bridge_service = __esm({
481
768
  }
482
769
  releasePendingRequest(requestId) {
483
770
  const request = this.pendingRequests.get(requestId);
484
- if (request)
771
+ if (request) {
485
772
  request.inFlight = false;
773
+ this.updateRequestStatus(requestId, { state: "queued" });
774
+ this.persistJournal();
775
+ }
486
776
  }
487
777
  releasePendingRequestsForSession(pluginSessionId) {
488
778
  const instance = this.instances.get(pluginSessionId);
@@ -491,24 +781,84 @@ var init_bridge_service = __esm({
491
781
  for (const request of this.pendingRequests.values()) {
492
782
  if (request.targetInstanceId === instance.instanceId && request.targetRole === instance.role) {
493
783
  request.inFlight = false;
784
+ request.assignedPluginSessionId = void 0;
785
+ this.updateRequestStatus(request.id, { state: "queued" });
494
786
  }
495
787
  }
788
+ this.persistJournal();
789
+ }
790
+ reconcilePluginReceipts(pluginSessionId, serverEpoch, receipts) {
791
+ if (serverEpoch !== this.serverEpoch)
792
+ return 0;
793
+ const instance = this.instances.get(pluginSessionId);
794
+ if (!instance)
795
+ return 0;
796
+ let reconciled = 0;
797
+ for (const receipt of receipts) {
798
+ const status = this.requestStatuses.get(receipt.requestId);
799
+ if (status && status.state === "outcome_unknown") {
800
+ status.state = "completed";
801
+ status.updatedAt = receipt.completedAt || Date.now();
802
+ status.response = { reconciled: true, digest: receipt.responseDigest };
803
+ reconciled++;
804
+ }
805
+ }
806
+ if (reconciled > 0) {
807
+ this.persistJournal();
808
+ }
809
+ return reconciled;
810
+ }
811
+ acknowledgeRequest(requestId) {
812
+ const status = this.requestStatuses.get(requestId);
813
+ if (!status)
814
+ return false;
815
+ this.updateRequestStatus(requestId, { state: "started" });
816
+ this.persistJournal();
817
+ return true;
496
818
  }
497
819
  resolveRequest(requestId, response) {
498
820
  const request = this.pendingRequests.get(requestId);
821
+ this.updateRequestStatus(requestId, { state: "completed", response, error: void 0 });
822
+ this.transportCounters.completed++;
499
823
  if (request) {
500
824
  clearTimeout(request.timeoutId);
501
825
  this.pendingRequests.delete(requestId);
502
826
  request.resolve(response);
503
827
  }
828
+ this.persistJournal();
504
829
  }
505
830
  rejectRequest(requestId, error) {
506
831
  const request = this.pendingRequests.get(requestId);
832
+ this.updateRequestStatus(requestId, { state: "failed", error });
507
833
  if (request) {
508
834
  clearTimeout(request.timeoutId);
509
835
  this.pendingRequests.delete(requestId);
510
836
  request.reject(error);
511
837
  }
838
+ this.persistJournal();
839
+ }
840
+ resolveFencedRequest(requestId, response, fence) {
841
+ const status = this.requestStatuses.get(requestId);
842
+ const pending = this.pendingRequests.get(requestId);
843
+ if (!status || status.serverEpoch !== fence.serverEpoch || status.deliveryAttempt !== fence.deliveryAttempt || status.leaseToken !== fence.leaseToken || status.state === "completed" || status.state === "failed" || pending?.assignedPluginSessionId !== fence.pluginSessionId)
844
+ return false;
845
+ this.resolveRequest(requestId, response);
846
+ return true;
847
+ }
848
+ rejectFencedRequest(requestId, error, fence) {
849
+ const status = this.requestStatuses.get(requestId);
850
+ const pending = this.pendingRequests.get(requestId);
851
+ if (!status || status.serverEpoch !== fence.serverEpoch || status.deliveryAttempt !== fence.deliveryAttempt || status.leaseToken !== fence.leaseToken || status.state === "completed" || status.state === "failed" || pending?.assignedPluginSessionId !== fence.pluginSessionId)
852
+ return false;
853
+ this.rejectRequest(requestId, error);
854
+ return true;
855
+ }
856
+ acknowledgeFencedRequest(requestId, fence) {
857
+ const status = this.requestStatuses.get(requestId);
858
+ const pending = this.pendingRequests.get(requestId);
859
+ if (!status || status.serverEpoch !== fence.serverEpoch || status.deliveryAttempt !== fence.deliveryAttempt || status.leaseToken !== fence.leaseToken || status.state === "completed" || status.state === "failed" || pending?.assignedPluginSessionId !== fence.pluginSessionId)
860
+ return false;
861
+ return this.acknowledgeRequest(requestId);
512
862
  }
513
863
  cleanupOldRequests() {
514
864
  const now = Date.now();
@@ -527,6 +877,48 @@ var init_bridge_service = __esm({
527
877
  }
528
878
  this.pendingRequests.clear();
529
879
  }
880
+ cancelRequest(requestId) {
881
+ const request = this.pendingRequests.get(requestId);
882
+ if (!request)
883
+ return false;
884
+ const status = this.requestStatuses.get(requestId);
885
+ if (status?.state === "started") {
886
+ return false;
887
+ }
888
+ clearTimeout(request.timeoutId);
889
+ this.pendingRequests.delete(requestId);
890
+ this.updateRequestStatus(requestId, { state: "cancelled", error: "Request cancelled before execution" });
891
+ this.transportCounters.cancelled++;
892
+ request.reject(new Error(`Request ${requestId} cancelled`));
893
+ this.persistJournal();
894
+ return true;
895
+ }
896
+ getCancellationEvents(pluginSessionId) {
897
+ const instance = this.instances.get(pluginSessionId);
898
+ if (!instance)
899
+ return [];
900
+ const events = [];
901
+ for (const request of this.pendingRequests.values()) {
902
+ if (request.targetInstanceId === instance.instanceId && request.targetRole === instance.role && this.requestStatuses.get(request.id)?.state === "started" && request.cancellationRequested) {
903
+ events.push(request.id);
904
+ }
905
+ }
906
+ return events;
907
+ }
908
+ async requestCancellation(requestId) {
909
+ const request = this.pendingRequests.get(requestId);
910
+ if (!request)
911
+ return false;
912
+ const status = this.requestStatuses.get(requestId);
913
+ if (status?.state === "started") {
914
+ request.cancellationRequested = true;
915
+ return true;
916
+ }
917
+ return this.cancelRequest(requestId);
918
+ }
919
+ isMutation(endpoint) {
920
+ return !endpoint.startsWith("/api/read") && !endpoint.startsWith("/api/get") && !endpoint.startsWith("/api/search") && endpoint !== "/api/evaluate" && endpoint !== "/api/execute-script" && endpoint !== "/api/run-code";
921
+ }
530
922
  };
531
923
  }
532
924
  });
@@ -593,7 +985,12 @@ function isRetryable(code) {
593
985
  }
594
986
  function toolErrorResult(error, stage) {
595
987
  const message = error instanceof Error ? error.message : String(error);
596
- const env = errorEnvelope(message, stage ? { stage } : {});
988
+ const unknownOutcome = error;
989
+ const busy = error;
990
+ const env = errorEnvelope(message, {
991
+ ...stage ? { stage } : {},
992
+ ...unknownOutcome?.outcome === "unknown" && typeof unknownOutcome.requestId === "string" ? { code: "OUTCOME_UNKNOWN", details: { requestId: unknownOutcome.requestId, outcome: "unknown" } } : typeof busy.retryAfterMs === "number" ? { code: "BUSY", details: { retryAfterMs: busy.retryAfterMs } } : {}
993
+ });
597
994
  return { content: [{ type: "text", text: JSON.stringify(env) }], isError: true };
598
995
  }
599
996
  function errorEnvelope(message, opts = {}) {
@@ -635,6 +1032,8 @@ var init_errors = __esm({
635
1032
  "use strict";
636
1033
  PATTERNS = [
637
1034
  // Order matters: more specific first.
1035
+ [/outcome\s+is\s+unknown|outcome_unknown/i, "OUTCOME_UNKNOWN"],
1036
+ [/bridge queue is busy|\bbusy\b/i, "BUSY"],
638
1037
  [/\b429\b|rate.?limit/i, "RATE_LIMITED"],
639
1038
  [/confirm(ation)?\s+required|requires?\s+confirm|pass\s+confirm/i, "CONFIRMATION_REQUIRED"],
640
1039
  [/multiple\s+(places|instances)|ambiguous\s+target|which\s+instance|specify\s+instance_id/i, "AMBIGUOUS_TARGET"],
@@ -648,11 +1047,14 @@ var init_errors = __esm({
648
1047
  [/required|missing\s+(argument|parameter|field)|must\s+be\s+(a|an)\b/i, "INVALID_ARGUMENT"]
649
1048
  ];
650
1049
  RETRYABLE = /* @__PURE__ */ new Set([
1050
+ "BUSY",
651
1051
  "TIMEOUT",
652
1052
  "RATE_LIMITED",
653
1053
  "PLUGIN_DISCONNECTED"
654
1054
  ]);
655
1055
  RECOVERY = {
1056
+ BUSY: "wait for retryAfterMs, then retry the request",
1057
+ OUTCOME_UNKNOWN: "call get_request_status with details.requestId before deciding whether to retry",
656
1058
  TIMEOUT: "retry; for heavy code prefer execute_luau_async",
657
1059
  RATE_LIMITED: "back off and retry shortly",
658
1060
  PLUGIN_DISCONNECTED: "check the Studio plugin is connected, then retry",
@@ -697,6 +1099,40 @@ var init_structured_output = __esm({
697
1099
  }
698
1100
  });
699
1101
 
1102
+ // ../core/dist/capability-policy.js
1103
+ function requiredCapability(toolName, category) {
1104
+ if (EXECUTE_TOOLS.test(toolName))
1105
+ return "execute.luau";
1106
+ if (ASSET_TOOLS.test(toolName))
1107
+ return "assets.external";
1108
+ if (PLAYTEST_TOOLS.test(toolName))
1109
+ return "playtest.control";
1110
+ if (PROPERTY_TOOLS.test(toolName))
1111
+ return "write.properties";
1112
+ return category === "write" ? "write.instances" : "read.scene";
1113
+ }
1114
+ function parseCapabilities(value) {
1115
+ if (!value?.trim())
1116
+ return void 0;
1117
+ return new Set(value.split(",").map((entry) => entry.trim()).filter(Boolean));
1118
+ }
1119
+ function parseClientCapabilities(value) {
1120
+ if (!value?.trim())
1121
+ return /* @__PURE__ */ new Map();
1122
+ const parsed = JSON.parse(value);
1123
+ return new Map(Object.entries(parsed).map(([token, capabilities]) => [token, new Set(capabilities)]));
1124
+ }
1125
+ var PROPERTY_TOOLS, EXECUTE_TOOLS, ASSET_TOOLS, PLAYTEST_TOOLS;
1126
+ var init_capability_policy = __esm({
1127
+ "../core/dist/capability-policy.js"() {
1128
+ "use strict";
1129
+ PROPERTY_TOOLS = /^(set_property|set_properties|mass_set_property|set_attribute|bulk_set_attributes|add_tag|remove_tag|delete_attribute)$/;
1130
+ EXECUTE_TOOLS = /^(execute_luau|execute_luau_async|eval_.*runtime|apply_mutation_plan)$/;
1131
+ ASSET_TOOLS = /(asset|wally|publish|import_rbxm|export_rbxm)/;
1132
+ PLAYTEST_TOOLS = /(playtest|multiplayer|simulate_(mouse|keyboard)|character_navigation)/;
1133
+ }
1134
+ });
1135
+
700
1136
  // ../core/dist/server-instructions.js
701
1137
  var SERVER_INSTRUCTIONS;
702
1138
  var init_server_instructions = __esm({
@@ -886,8 +1322,8 @@ function parseResourceUri(uri) {
886
1322
  return { kind: "changes", since };
887
1323
  }
888
1324
  if (segments[0] === "node" && segments.length >= 2) {
889
- const path7 = decodeURIComponent(segments.slice(1).join("/"));
890
- return { kind: "node", path: path7 };
1325
+ const path9 = decodeURIComponent(segments.slice(1).join("/"));
1326
+ return { kind: "node", path: path9 };
891
1327
  }
892
1328
  if (segments[0] === "playtest" && segments[1] === "episode" && segments.length >= 3) {
893
1329
  return { kind: "episode", id: decodeURIComponent(segments.slice(2).join("/")) };
@@ -1104,6 +1540,20 @@ var init_tool_catalog = __esm({
1104
1540
  "load_toolset",
1105
1541
  "get_roblox_docs",
1106
1542
  "get_session_summary",
1543
+ "get_request_status",
1544
+ "get_transport_diagnostics",
1545
+ "cancel_request",
1546
+ "detect_roblox_project",
1547
+ "validate_script_source",
1548
+ "format_script_preview",
1549
+ "resolve_instance_source_file",
1550
+ "run_project_tests",
1551
+ "get_dependency_graph",
1552
+ "install_wally_packages",
1553
+ "run_quality_gate",
1554
+ "validate_with_luau_lsp",
1555
+ "generate_rojo_sourcemap",
1556
+ "build_rojo_project",
1107
1557
  "get_connected_instances",
1108
1558
  "get_scene_summary",
1109
1559
  "execute_luau",
@@ -1209,7 +1659,6 @@ var init_tool_catalog = __esm({
1209
1659
 
1210
1660
  // ../core/dist/http-server.js
1211
1661
  import express from "express";
1212
- import cors from "cors";
1213
1662
  import http from "http";
1214
1663
  import { WebSocketServer, WebSocket } from "ws";
1215
1664
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
@@ -1217,6 +1666,23 @@ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/
1217
1666
  import { CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, ListResourcesRequestSchema, ListResourceTemplatesRequestSchema, ReadResourceRequestSchema, SubscribeRequestSchema, UnsubscribeRequestSchema, McpError } from "@modelcontextprotocol/sdk/types.js";
1218
1667
  function createHttpServer(tools, bridge, allowedTools, serverConfig, registry) {
1219
1668
  const app = express();
1669
+ const requirePluginAuth = process.env.NODE_ENV !== "test";
1670
+ const bearerToken = (req) => {
1671
+ const value = req.header("authorization");
1672
+ return value?.startsWith("Bearer ") ? value.slice(7) : void 0;
1673
+ };
1674
+ const pluginAuthorized = (req, pluginSessionId) => !requirePluginAuth || !!pluginSessionId && bridge.authenticatePlugin(pluginSessionId, bearerToken(req) ?? "");
1675
+ const serverSessionToken = process.env.BLOXFORGE_SESSION_TOKEN?.trim();
1676
+ const clientCapabilities = parseClientCapabilities(process.env.BLOXFORGE_CLIENT_CAPABILITIES_JSON);
1677
+ app.use((req, res, next) => {
1678
+ const protectedServerRoute = req.path === "/proxy" || req.path === "/mcp" || req.path.startsWith("/mcp/");
1679
+ const token = bearerToken(req);
1680
+ if (protectedServerRoute && (serverSessionToken || clientCapabilities.size > 0) && token !== serverSessionToken && !clientCapabilities.has(token ?? "")) {
1681
+ res.status(401).json({ error: "invalid_session_token" });
1682
+ return;
1683
+ }
1684
+ next();
1685
+ });
1220
1686
  let mcpServerActive = false;
1221
1687
  let lastMCPActivity = 0;
1222
1688
  let mcpServerStartTime = 0;
@@ -1250,9 +1716,9 @@ function createHttpServer(tools, bridge, allowedTools, serverConfig, registry) {
1250
1716
  const isLazyTools = () => registry ? registry.lazyMode : !(envLazyFlag === "0" || envLazyFlag === "false" || envLazyFlag === "off");
1251
1717
  const getActiveToolCount = () => isLazyTools() ? registry ? registry.activeNames.size : CORE_TOOLS.size : serverConfig?.tools.length ?? 0;
1252
1718
  const getLoadedToolsets = () => isLazyTools() ? [process.env.BLOXFORGE_TOOL_PROFILE?.trim().toLowerCase() || "core"] : ["all"];
1253
- app.use(cors());
1254
- app.use(express.json({ limit: "50mb" }));
1255
- app.use(express.urlencoded({ limit: "50mb", extended: true }));
1719
+ const bodyLimit = process.env.MCP_HTTP_BODY_LIMIT?.trim() || "50mb";
1720
+ app.use(express.json({ limit: bodyLimit }));
1721
+ app.use(express.urlencoded({ limit: bodyLimit, extended: true }));
1256
1722
  app.get("/health", (req, res) => {
1257
1723
  const instances = bridge.getInstances();
1258
1724
  const publicInstances = instances.map(toPublic);
@@ -1355,6 +1821,8 @@ function createHttpServer(tools, bridge, allowedTools, serverConfig, registry) {
1355
1821
  success: true,
1356
1822
  assignedRole: result.assignedRole,
1357
1823
  instanceId: result.instanceId,
1824
+ sessionToken: result.sessionToken,
1825
+ serverEpoch: bridge.serverEpoch,
1358
1826
  serverVersion: serverConfig?.version,
1359
1827
  serverProtocolVersion: MCP_PROTOCOL_VERSION,
1360
1828
  versionMismatch: registered?.versionMismatch ?? false,
@@ -1364,6 +1832,10 @@ function createHttpServer(tools, bridge, allowedTools, serverConfig, registry) {
1364
1832
  });
1365
1833
  app.post("/disconnect", (req, res) => {
1366
1834
  const { pluginSessionId } = req.body;
1835
+ if (!pluginAuthorized(req, pluginSessionId)) {
1836
+ res.status(401).json({ success: false, error: "invalid_session_token" });
1837
+ return;
1838
+ }
1367
1839
  if (pluginSessionId) {
1368
1840
  bridge.unregisterInstance(pluginSessionId, "plugin_request");
1369
1841
  warnedVersionMismatches.delete(pluginSessionId);
@@ -1380,6 +1852,7 @@ function createHttpServer(tools, bridge, allowedTools, serverConfig, registry) {
1380
1852
  instances: publicInstances,
1381
1853
  serverVersion: serverConfig?.version,
1382
1854
  protocolVersion: MCP_PROTOCOL_VERSION,
1855
+ serverEpoch: bridge.serverEpoch,
1383
1856
  versionMismatch: publicInstances.some((inst) => inst.versionMismatch),
1384
1857
  protocolMismatch: publicInstances.some((inst) => inst.protocolMismatch),
1385
1858
  lazyTools: isLazyTools(),
@@ -1435,6 +1908,10 @@ function createHttpServer(tools, bridge, allowedTools, serverConfig, registry) {
1435
1908
  });
1436
1909
  app.get("/poll", (req, res) => {
1437
1910
  const pluginSessionId = req.query.pluginSessionId;
1911
+ if (!pluginAuthorized(req, pluginSessionId)) {
1912
+ res.status(401).json({ error: "invalid_session_token", knownInstance: false, request: null });
1913
+ return;
1914
+ }
1438
1915
  if (pluginSessionId) {
1439
1916
  bridge.updateInstanceActivity(pluginSessionId);
1440
1917
  }
@@ -1476,11 +1953,14 @@ function createHttpServer(tools, bridge, allowedTools, serverConfig, registry) {
1476
1953
  });
1477
1954
  return;
1478
1955
  }
1479
- const pendingRequest = knownInstance && callerInstanceId && callerRole ? bridge.getPendingRequest(callerInstanceId, callerRole) : null;
1956
+ const pendingRequest = knownInstance && pluginSessionId ? bridge.getPendingRequestForSession(pluginSessionId) : null;
1480
1957
  if (pendingRequest) {
1481
1958
  res.json({
1482
1959
  request: pendingRequest.request,
1483
1960
  requestId: pendingRequest.requestId,
1961
+ serverEpoch: pendingRequest.serverEpoch,
1962
+ deliveryAttempt: pendingRequest.deliveryAttempt,
1963
+ leaseToken: pendingRequest.leaseToken,
1484
1964
  mcpConnected: true,
1485
1965
  pluginConnected: true,
1486
1966
  knownInstance,
@@ -1491,7 +1971,8 @@ function createHttpServer(tools, bridge, allowedTools, serverConfig, registry) {
1491
1971
  protocolVersion: callerPluginProtocolVersion,
1492
1972
  versionMismatch,
1493
1973
  protocolMismatch,
1494
- proxyInstanceCount: proxyInstances.size
1974
+ proxyInstanceCount: proxyInstances.size,
1975
+ cancellations: pluginSessionId ? bridge.getCancellationEvents(pluginSessionId) : []
1495
1976
  });
1496
1977
  } else {
1497
1978
  res.json({
@@ -1506,16 +1987,79 @@ function createHttpServer(tools, bridge, allowedTools, serverConfig, registry) {
1506
1987
  protocolVersion: callerPluginProtocolVersion,
1507
1988
  versionMismatch,
1508
1989
  protocolMismatch,
1509
- proxyInstanceCount: proxyInstances.size
1990
+ proxyInstanceCount: proxyInstances.size,
1991
+ cancellations: pluginSessionId ? bridge.getCancellationEvents(pluginSessionId) : []
1510
1992
  });
1511
1993
  }
1512
1994
  });
1995
+ app.post("/reconcile", (req, res) => {
1996
+ const pluginSessionId = typeof req.body?.pluginSessionId === "string" ? req.body.pluginSessionId : void 0;
1997
+ const serverEpoch = typeof req.body?.serverEpoch === "string" ? req.body.serverEpoch : void 0;
1998
+ const receipts = Array.isArray(req.body?.receipts) ? req.body.receipts : void 0;
1999
+ if (!pluginAuthorized(req, pluginSessionId)) {
2000
+ res.status(401).json({ error: "invalid_session_token" });
2001
+ return;
2002
+ }
2003
+ if (!pluginSessionId || !serverEpoch || !receipts) {
2004
+ res.status(400).json({ error: "missing_reconcile_fields" });
2005
+ return;
2006
+ }
2007
+ const reconciled = bridge.reconcilePluginReceipts(pluginSessionId, serverEpoch, receipts);
2008
+ res.json({ success: true, reconciled });
2009
+ });
2010
+ app.post("/ack", (req, res) => {
2011
+ const requestId = typeof req.body?.requestId === "string" ? req.body.requestId : "";
2012
+ const pluginSessionId = typeof req.body?.pluginSessionId === "string" ? req.body.pluginSessionId : void 0;
2013
+ if (!pluginAuthorized(req, pluginSessionId)) {
2014
+ res.status(401).json({ error: "invalid_session_token", requestId });
2015
+ return;
2016
+ }
2017
+ const fence = {
2018
+ serverEpoch: req.body?.serverEpoch,
2019
+ pluginSessionId: pluginSessionId ?? "",
2020
+ deliveryAttempt: req.body?.deliveryAttempt,
2021
+ leaseToken: req.body?.leaseToken
2022
+ };
2023
+ const requiresFence = !!pluginSessionId && (bridge.getInstanceBySessionId(pluginSessionId)?.pluginProtocolVersion ?? 0) >= 3;
2024
+ if (!requestId || !bridge.getRequestStatus(requestId)) {
2025
+ res.status(404).json({ error: "not_found", requestId });
2026
+ return;
2027
+ }
2028
+ const acknowledged = requiresFence ? bridge.acknowledgeFencedRequest(requestId, fence) : bridge.acknowledgeRequest(requestId);
2029
+ if (!acknowledged) {
2030
+ res.status(409).json({ error: "stale_or_unknown_delivery", requestId });
2031
+ return;
2032
+ }
2033
+ res.json({ success: true, requestId });
2034
+ });
2035
+ app.post("/cancel", (req, res) => {
2036
+ const requestId = typeof req.body?.requestId === "string" ? req.body.requestId : "";
2037
+ if (!requestId || !bridge.cancelRequest(requestId)) {
2038
+ res.status(409).json({ success: false, error: "request_not_cancellable", requestId });
2039
+ return;
2040
+ }
2041
+ res.json({ success: true, requestId, state: "cancelled" });
2042
+ });
2043
+ app.get("/request/:requestId/status", (req, res) => {
2044
+ const status = bridge.getRequestStatus(req.params.requestId);
2045
+ if (!status) {
2046
+ res.status(404).json({ error: "request_not_found", requestId: req.params.requestId });
2047
+ return;
2048
+ }
2049
+ res.json(status);
2050
+ });
1513
2051
  app.post("/response", (req, res) => {
1514
- const { requestId, response, error } = req.body;
1515
- if (error) {
1516
- bridge.rejectRequest(requestId, error);
1517
- } else {
1518
- bridge.resolveRequest(requestId, response);
2052
+ const { requestId, response, error, pluginSessionId, serverEpoch, deliveryAttempt, leaseToken } = req.body;
2053
+ if (!pluginAuthorized(req, pluginSessionId)) {
2054
+ res.status(401).json({ success: false, error: "invalid_session_token", requestId });
2055
+ return;
2056
+ }
2057
+ const fence = { serverEpoch, pluginSessionId, deliveryAttempt, leaseToken };
2058
+ const requiresFence = (bridge.getInstanceBySessionId(pluginSessionId)?.pluginProtocolVersion ?? 0) >= 3;
2059
+ const accepted = requiresFence ? error ? bridge.rejectFencedRequest(requestId, error, fence) : bridge.resolveFencedRequest(requestId, response, fence) : error ? (bridge.rejectRequest(requestId, error), true) : (bridge.resolveRequest(requestId, response), true);
2060
+ if (!accepted) {
2061
+ res.status(409).json({ success: false, error: "stale_or_unknown_delivery", requestId });
2062
+ return;
1519
2063
  }
1520
2064
  res.json({ success: true });
1521
2065
  });
@@ -1532,7 +2076,10 @@ function createHttpServer(tools, bridge, allowedTools, serverConfig, registry) {
1532
2076
  const response = await bridge.sendRequest(endpoint, data, targetInstanceId, targetRole);
1533
2077
  res.json({ response });
1534
2078
  } catch (err) {
1535
- res.status(500).json({ error: err.message || "Proxy request failed" });
2079
+ res.status(500).json({
2080
+ error: err.message || "Proxy request failed",
2081
+ ...err instanceof RequestOutcomeUnknownError ? { outcome: err.outcome, requestId: err.requestId } : {}
2082
+ });
1536
2083
  }
1537
2084
  });
1538
2085
  if (serverConfig) {
@@ -1563,6 +2110,12 @@ function createHttpServer(tools, bridge, allowedTools, serverConfig, registry) {
1563
2110
  if (allowedTools && !allowedTools.has(name)) {
1564
2111
  throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}`);
1565
2112
  }
2113
+ const definition = legacyFilteredTools.find((tool) => tool.name === name);
2114
+ const capabilities = clientCapabilities.get(bearerToken(req) ?? "");
2115
+ const capability = definition ? requiredCapability(name, definition.category) : void 0;
2116
+ if (capability && capabilities && !capabilities.has(capability)) {
2117
+ throw new McpError(ErrorCode.InvalidRequest, `Capability required: ${capability}`);
2118
+ }
1566
2119
  try {
1567
2120
  if (registry) {
1568
2121
  const registryResult = await registry.callTool(name, tools, args || {});
@@ -1694,12 +2247,12 @@ function createHttpServer(tools, bridge, allowedTools, serverConfig, registry) {
1694
2247
  return app;
1695
2248
  }
1696
2249
  function listenWithRetry(app, host, startPort, maxAttempts = 5) {
1697
- return new Promise(async (resolve6, reject) => {
2250
+ return new Promise(async (resolve7, reject) => {
1698
2251
  for (let i = 0; i < maxAttempts; i++) {
1699
2252
  const port = startPort + i;
1700
2253
  try {
1701
2254
  const server = await bindPort(app, host, port);
1702
- resolve6({ server, port });
2255
+ resolve7({ server, port });
1703
2256
  return;
1704
2257
  } catch (err) {
1705
2258
  if (err.code === "EADDRINUSE") {
@@ -1714,7 +2267,7 @@ function listenWithRetry(app, host, startPort, maxAttempts = 5) {
1714
2267
  });
1715
2268
  }
1716
2269
  function bindPort(app, host, port) {
1717
- return new Promise((resolve6, reject) => {
2270
+ return new Promise((resolve7, reject) => {
1718
2271
  const server = http.createServer(app);
1719
2272
  attachBridgeWebSocket(server, app.bridge);
1720
2273
  const onError = (err) => {
@@ -1724,11 +2277,12 @@ function bindPort(app, host, port) {
1724
2277
  server.once("error", onError);
1725
2278
  server.listen(port, host, () => {
1726
2279
  server.removeListener("error", onError);
1727
- resolve6(server);
2280
+ resolve7(server);
1728
2281
  });
1729
2282
  });
1730
2283
  }
1731
2284
  function attachBridgeWebSocket(server, bridge) {
2285
+ const requirePluginAuth = process.env.NODE_ENV !== "test";
1732
2286
  const streams = /* @__PURE__ */ new Map();
1733
2287
  const wss = new WebSocketServer({ noServer: true });
1734
2288
  const deliver = (pluginSessionId) => {
@@ -1749,7 +2303,8 @@ function attachBridgeWebSocket(server, bridge) {
1749
2303
  if (url.pathname !== "/stream")
1750
2304
  return;
1751
2305
  const pluginSessionId = url.searchParams.get("pluginSessionId");
1752
- if (!pluginSessionId || !bridge.getInstanceBySessionId(pluginSessionId)) {
2306
+ const sessionToken = url.searchParams.get("sessionToken") ?? void 0;
2307
+ if (!pluginSessionId || !bridge.getInstanceBySessionId(pluginSessionId) || requirePluginAuth && !bridge.authenticatePlugin(pluginSessionId, sessionToken ?? "")) {
1753
2308
  socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
1754
2309
  socket.destroy();
1755
2310
  return;
@@ -1764,9 +2319,30 @@ function attachBridgeWebSocket(server, bridge) {
1764
2319
  bridge.updateInstanceActivity(pluginSessionId);
1765
2320
  try {
1766
2321
  const message = JSON.parse(raw.toString());
1767
- if (message.type !== "response" || typeof message.requestId !== "string")
2322
+ if (typeof message.requestId !== "string")
2323
+ return;
2324
+ const fence = {
2325
+ serverEpoch: message.serverEpoch,
2326
+ pluginSessionId,
2327
+ deliveryAttempt: message.deliveryAttempt,
2328
+ leaseToken: message.leaseToken
2329
+ };
2330
+ const requiresFence = (bridge.getInstanceBySessionId(pluginSessionId)?.pluginProtocolVersion ?? 0) >= 3;
2331
+ if (message.type === "ack") {
2332
+ if (requiresFence)
2333
+ bridge.acknowledgeFencedRequest(message.requestId, fence);
2334
+ else
2335
+ bridge.acknowledgeRequest(message.requestId);
1768
2336
  return;
1769
- if (message.error)
2337
+ }
2338
+ if (message.type !== "response")
2339
+ return;
2340
+ if (requiresFence) {
2341
+ if (message.error)
2342
+ bridge.rejectFencedRequest(message.requestId, message.error, fence);
2343
+ else
2344
+ bridge.resolveFencedRequest(message.requestId, message.response, fence);
2345
+ } else if (message.error)
1770
2346
  bridge.rejectRequest(message.requestId, message.error);
1771
2347
  else
1772
2348
  bridge.resolveRequest(message.requestId, message.response);
@@ -1788,12 +2364,27 @@ var init_http_server = __esm({
1788
2364
  init_tool_shape();
1789
2365
  init_errors();
1790
2366
  init_structured_output();
2367
+ init_capability_policy();
1791
2368
  init_server_instructions();
1792
2369
  init_resources();
1793
2370
  init_tool_catalog();
1794
2371
  TOOL_HANDLERS = {
1795
2372
  get_roblox_docs: (tools, body) => tools.getRobloxDocs(body.name, body.doc_type, body.section),
1796
2373
  get_session_summary: (tools) => tools.getSessionSummary(),
2374
+ get_request_status: (tools, body) => tools.getRequestStatus(body.requestId),
2375
+ get_transport_diagnostics: (tools) => tools.getTransportDiagnostics(),
2376
+ cancel_request: (tools, body) => tools.cancelRequest(body.requestId),
2377
+ detect_roblox_project: (tools, body) => tools.detectRobloxProject(body.root),
2378
+ validate_script_source: (tools, body) => tools.validateScriptSource(body.source, body.fileName),
2379
+ format_script_preview: (tools, body) => tools.formatScriptPreview(body.source, body.fileName),
2380
+ resolve_instance_source_file: (tools, body) => tools.resolveInstanceSourceFile(body.instancePath, body.root),
2381
+ run_project_tests: (tools, body) => tools.runProjectTests(body.root, body.script),
2382
+ get_dependency_graph: (tools, body) => tools.getDependencyGraph(body.root),
2383
+ install_wally_packages: (tools, body) => tools.installWallyPackages(body.root, body.confirm),
2384
+ run_quality_gate: (tools, body) => tools.runQualityGate(body.root),
2385
+ validate_with_luau_lsp: (tools, body) => tools.validateWithLuauLsp(body.root, body.files),
2386
+ generate_rojo_sourcemap: (tools, body) => tools.generateRojoSourcemap(body.root, body.output),
2387
+ build_rojo_project: (tools, body) => tools.buildRojoProject(body.root, body.output),
1797
2388
  tool_catalog_search: (tools, body) => tools.toolCatalogSearch(body),
1798
2389
  load_toolset: (tools, body) => tools.loadToolset(body),
1799
2390
  get_world_snapshot: (tools, body) => tools.getWorldSnapshot(body.path, body.level, body.topNPerClass, body.instance_id),
@@ -1801,7 +2392,7 @@ var init_http_server = __esm({
1801
2392
  get_changes_since: (tools, body) => tools.getChangesSince(body.snapshotId, body.path, body.instance_id),
1802
2393
  scene_search: (tools, body) => tools.sceneSearch(body.query, body.path, body.limit, body.instance_id),
1803
2394
  playtest_sample_state: (tools, body) => tools.playtestSampleState(body.domains, body.target, body.instance_id),
1804
- apply_mutation_plan: (tools, body) => tools.applyMutationPlan(body.operations, body.dryRun, body.confirm, body.instance_id),
2395
+ apply_mutation_plan: (tools, body) => tools.applyMutationPlan(body.operations, body.dryRun, body.confirm, body.instance_id, body.atomic),
1805
2396
  list_recipes: (tools) => tools.listRecipes(),
1806
2397
  apply_recipe: (tools, body) => tools.applyRecipe(body.recipe, body.params, body.instance_id),
1807
2398
  run_gameplay_assertions: (tools, body) => tools.runGameplayAssertions(body.assertions, body.target, body.instance_id),
@@ -2187,8 +2778,8 @@ var init_safety_manager = __esm({
2187
2778
  }
2188
2779
  return { allowed, requiresConfirmation, blocked, dryRun: false, reasons, warnings, matchedPatterns };
2189
2780
  }
2190
- isProtectedPath(path7) {
2191
- const trimmed = path7.trim();
2781
+ isProtectedPath(path9) {
2782
+ const trimmed = path9.trim();
2192
2783
  return this.config.protectedPaths.some((p) => p === trimmed || trimmed === `game.${p}`);
2193
2784
  }
2194
2785
  recordOperation(record) {
@@ -2201,10 +2792,10 @@ var init_safety_manager = __esm({
2201
2792
  getHistory() {
2202
2793
  return [...this.history].reverse();
2203
2794
  }
2204
- backupScript(path7, source) {
2205
- const existing = this.backups.get(path7);
2206
- this.backups.set(path7, {
2207
- path: path7,
2795
+ backupScript(path9, source) {
2796
+ const existing = this.backups.get(path9);
2797
+ this.backups.set(path9, {
2798
+ path: path9,
2208
2799
  source,
2209
2800
  previous: existing?.source,
2210
2801
  timestamp: Date.now()
@@ -2215,8 +2806,8 @@ var init_safety_manager = __esm({
2215
2806
  this.backups.delete(oldestKey);
2216
2807
  }
2217
2808
  }
2218
- getBackup(path7) {
2219
- return this.backups.get(path7);
2809
+ getBackup(path9) {
2810
+ return this.backups.get(path9);
2220
2811
  }
2221
2812
  listBackups() {
2222
2813
  return [...this.backups.values()].sort((a, b) => b.timestamp - a.timestamp);
@@ -2697,6 +3288,7 @@ local parent = resolvePath(${luaString(parent)})
2697
3288
  if not parent then return { error = "Parent not found: " .. ${luaString(parent)} } end
2698
3289
  local existing = parent:FindFirstChild(${luaString(name)})
2699
3290
  if existing then existing:Destroy() end
3291
+ if _G.__mcp and _G.__mcp.checkCancelled and _G.__mcp.checkCancelled() then return { cancelled = true } end
2700
3292
  local door = Instance.new("Part")
2701
3293
  door.Name = ${luaString(name)}
2702
3294
  door.Size = Vector3.new(8, 10, 1)
@@ -2733,6 +3325,7 @@ local group = SoundService:FindFirstChild(groupName)
2733
3325
  if not group then group = Instance.new("SoundGroup") group.Name = groupName group.Parent = SoundService end
2734
3326
  local existing = SoundService:FindFirstChild(${luaString(name)})
2735
3327
  if existing then existing:Destroy() end
3328
+ if _G.__mcp and _G.__mcp.checkCancelled and _G.__mcp.checkCancelled() then return { cancelled = true } end
2736
3329
  local sound = Instance.new("Sound")
2737
3330
  sound.Name = ${luaString(name)}
2738
3331
  sound.SoundId = ${luaString(soundId)}
@@ -2753,6 +3346,7 @@ local parent = resolvePath(${luaString(parent)})
2753
3346
  if not parent then return { error = "Parent not found: " .. ${luaString(parent)} } end
2754
3347
  local existing = parent:FindFirstChild(${luaString(name)})
2755
3348
  if existing then existing:Destroy() end
3349
+ if _G.__mcp and _G.__mcp.checkCancelled and _G.__mcp.checkCancelled() then return { cancelled = true } end
2756
3350
  local brick = Instance.new("Part")
2757
3351
  brick.Name = ${luaString(name)}
2758
3352
  brick.Size = Vector3.new(8, 1, 8)
@@ -3404,12 +3998,14 @@ var init_mutation = __esm({
3404
3998
  property: { type: "string", description: "For set_property." },
3405
3999
  name: { type: "string", description: "For set_attribute." },
3406
4000
  tag: { type: "string", description: "For add_tag / remove_tag." },
3407
- value: { type: ["boolean", "number", "string"], description: "For set_property / set_attribute (primitive)." }
4001
+ value: { type: ["boolean", "number", "string"], description: "For set_property / set_attribute (primitive)." },
4002
+ expected: { type: ["boolean", "number", "string", "null"], description: "Optional optimistic-lock value. The whole plan is rejected before writes when the current value differs." }
3408
4003
  },
3409
4004
  required: ["op", "target"]
3410
4005
  }
3411
4006
  },
3412
4007
  dryRun: { type: "boolean", description: "Preview the diff without applying (default false)." },
4008
+ atomic: { type: "boolean", description: "Rollback earlier successful operations when a later operation fails (default true)." },
3413
4009
  confirm: { type: "boolean", description: "Approve a large plan the safety layer gates." },
3414
4010
  instance_id: { type: "string", description: "Connected Studio place id. Required only when multiple places are open." }
3415
4011
  },
@@ -6828,6 +7424,102 @@ var init_meta = __esm({
6828
7424
  properties: {}
6829
7425
  }
6830
7426
  },
7427
+ {
7428
+ name: "get_request_status",
7429
+ category: "read",
7430
+ description: "Look up a bridge request after a timeout. Use the requestId from an outcome_unknown error before deciding whether a mutation is safe to retry.",
7431
+ inputSchema: {
7432
+ type: "object",
7433
+ properties: {
7434
+ requestId: { type: "string", description: "Bridge request id from an outcome_unknown error." }
7435
+ },
7436
+ required: ["requestId"]
7437
+ }
7438
+ },
7439
+ {
7440
+ name: "get_transport_diagnostics",
7441
+ category: "read",
7442
+ description: "Return payload-free local transport metrics: queue depth, retries, BUSY/outcome_unknown/cancel/completion counts, server epoch, and latency p50/p95/p99.",
7443
+ inputSchema: { type: "object", properties: {} }
7444
+ },
7445
+ {
7446
+ name: "cancel_request",
7447
+ category: "write",
7448
+ description: "Cancel a queued bridge request before the plugin acknowledges execution. Returns false once execution has started; this never claims to stop an already-running mutation.",
7449
+ inputSchema: {
7450
+ type: "object",
7451
+ properties: {
7452
+ requestId: { type: "string", description: "Bridge request id to cancel." }
7453
+ },
7454
+ required: ["requestId"]
7455
+ }
7456
+ },
7457
+ {
7458
+ name: "detect_roblox_project",
7459
+ category: "read",
7460
+ description: "Detect Rojo, Wally, Rokit, Selene, StyLua, and sourcemap files and report which optional quality tools are installed.",
7461
+ inputSchema: { type: "object", properties: { root: { type: "string", description: "Project directory (defaults to the server working directory)." } } }
7462
+ },
7463
+ {
7464
+ name: "validate_script_source",
7465
+ category: "read",
7466
+ description: "Run available luau-analyze, Selene, and StyLua checks on source without writing it to the Roblox DataModel. Missing binaries are reported explicitly.",
7467
+ inputSchema: { type: "object", properties: { source: { type: "string" }, fileName: { type: "string" } }, required: ["source"] }
7468
+ },
7469
+ {
7470
+ name: "format_script_preview",
7471
+ category: "read",
7472
+ description: "Return a StyLua formatting preview through stdin; user source is never formatted or written implicitly.",
7473
+ inputSchema: { type: "object", properties: { source: { type: "string" }, fileName: { type: "string" } }, required: ["source"] }
7474
+ },
7475
+ {
7476
+ name: "resolve_instance_source_file",
7477
+ category: "read",
7478
+ description: "Resolve an Instance path through a Rojo-style sourcemap.json when one is present.",
7479
+ inputSchema: { type: "object", properties: { instancePath: { type: "string" }, root: { type: "string" } }, required: ["instancePath"] }
7480
+ },
7481
+ {
7482
+ name: "run_project_tests",
7483
+ category: "write",
7484
+ description: "Run an explicitly selected Lune project test script; no test script is guessed.",
7485
+ inputSchema: { type: "object", properties: { root: { type: "string" }, script: { type: "string" } }, required: ["script"] }
7486
+ },
7487
+ {
7488
+ name: "get_dependency_graph",
7489
+ category: "read",
7490
+ description: "Read Wally manifest/lockfile metadata into a compact dependency list without installing packages.",
7491
+ inputSchema: { type: "object", properties: { root: { type: "string" } } }
7492
+ },
7493
+ {
7494
+ name: "install_wally_packages",
7495
+ category: "write",
7496
+ description: "Run wally install in the detected project only after explicit confirm=true.",
7497
+ inputSchema: { type: "object", properties: { root: { type: "string" }, confirm: { type: "boolean" } }, required: ["confirm"] }
7498
+ },
7499
+ {
7500
+ name: "run_quality_gate",
7501
+ category: "read",
7502
+ description: "Run available Rojo sourcemap, Selene, StyLua, and luau-lsp project checks and return structured per-tool results.",
7503
+ inputSchema: { type: "object", properties: { root: { type: "string" } } }
7504
+ },
7505
+ {
7506
+ name: "validate_with_luau_lsp",
7507
+ category: "read",
7508
+ description: "Run luau-lsp analyze against selected files, automatically using the detected Rojo sourcemap when present.",
7509
+ inputSchema: { type: "object", properties: { root: { type: "string" }, files: { type: "array", items: { type: "string" } } } }
7510
+ },
7511
+ {
7512
+ name: "generate_rojo_sourcemap",
7513
+ category: "write",
7514
+ description: "Generate a Rojo sourcemap from the detected project file into an explicit output path.",
7515
+ inputSchema: { type: "object", properties: { root: { type: "string" }, output: { type: "string" } } }
7516
+ },
7517
+ {
7518
+ name: "build_rojo_project",
7519
+ category: "write",
7520
+ description: "Build the detected Rojo project to an explicit RBXL/RBXM output path.",
7521
+ inputSchema: { type: "object", properties: { root: { type: "string" }, output: { type: "string" } }, required: ["output"] }
7522
+ },
6831
7523
  {
6832
7524
  name: "load_toolset",
6833
7525
  category: "read",
@@ -7357,8 +8049,8 @@ for _, p in ipairs(paths) do
7357
8049
  end
7358
8050
  return { nodes = out, count = #out }`;
7359
8051
  }
7360
- function buildWorldSnapshotLuau(path7 = "game", level = "overview", topNPerClass = 12) {
7361
- const safePath = luaString(path7);
8052
+ function buildWorldSnapshotLuau(path9 = "game", level = "overview", topNPerClass = 12) {
8053
+ const safePath = luaString(path9);
7362
8054
  const safeTopN = luaNumber(Math.max(1, Math.floor(topNPerClass)));
7363
8055
  return `${PATH_RESOLVER_LUA}
7364
8056
  local root = resolvePath(${safePath})
@@ -7370,6 +8062,8 @@ local soundCount, soundPlaying, soundLooped = 0, 0, 0
7370
8062
  local scriptCount, localScriptCount, moduleCount = 0, 0, 0
7371
8063
  local taggedCount = 0
7372
8064
  for _, d in ipairs(root:GetDescendants()) do
8065
+ if _G.__mcp and _G.__mcp.checkCancelled and _G.__mcp.checkCancelled() then return { cancelled = true } end
8066
+ if _G.__mcp and _G.__mcp.checkCancelled and _G.__mcp.checkCancelled() then return { cancelled = true } end
7373
8067
  total = total + 1
7374
8068
  byClass[d.ClassName] = (byClass[d.ClassName] or 0) + 1
7375
8069
  if d:IsA("Sound") then
@@ -7466,8 +8160,8 @@ end`;
7466
8160
  });
7467
8161
 
7468
8162
  // ../core/dist/builders/scene-search.js
7469
- function buildSceneSearchLuau(query, path7 = "game", limit = 10) {
7470
- const safePath = luaString(path7);
8163
+ function buildSceneSearchLuau(query, path9 = "game", limit = 10) {
8164
+ const safePath = luaString(path9);
7471
8165
  const safeQuery = luaString(query.toLowerCase());
7472
8166
  const safeLimit = luaNumber(Math.max(1, Math.min(50, Math.floor(limit))));
7473
8167
  return `${PATH_RESOLVER_LUA}
@@ -7487,6 +8181,8 @@ end
7487
8181
 
7488
8182
  local scored = {}
7489
8183
  for _, d in ipairs(root:GetDescendants()) do
8184
+ if _G.__mcp and _G.__mcp.checkCancelled and _G.__mcp.checkCancelled() then return { cancelled = true } end
8185
+ if _G.__mcp and _G.__mcp.checkCancelled and _G.__mcp.checkCancelled() then return { cancelled = true } end
7490
8186
  local name = lc(d.Name)
7491
8187
  local class = lc(d.ClassName)
7492
8188
  local parentName = d.Parent and lc(d.Parent.Name) or ""
@@ -7533,8 +8229,8 @@ var init_scene_search = __esm({
7533
8229
  });
7534
8230
 
7535
8231
  // ../core/dist/builders/world-fingerprint.js
7536
- function buildWorldFingerprintLuau(path7 = "game", maxNodes = 8e3) {
7537
- const safePath = luaString(path7);
8232
+ function buildWorldFingerprintLuau(path9 = "game", maxNodes = 8e3) {
8233
+ const safePath = luaString(path9);
7538
8234
  const safeMax = luaNumber(Math.max(1, Math.floor(maxNodes)));
7539
8235
  return `${PATH_RESOLVER_LUA}
7540
8236
  ${FINGERPRINT_HELPERS_LUA}
@@ -7544,6 +8240,8 @@ local fp = {}
7544
8240
  local count = 0
7545
8241
  local truncated = false
7546
8242
  for _, d in ipairs(root:GetDescendants()) do
8243
+ if _G.__mcp and _G.__mcp.checkCancelled and _G.__mcp.checkCancelled() then return { cancelled = true } end
8244
+ if _G.__mcp and _G.__mcp.checkCancelled and _G.__mcp.checkCancelled() then return { cancelled = true } end
7547
8245
  if count >= ${safeMax} then truncated = true break end
7548
8246
  local id = nid(d)
7549
8247
  fp[id] = {
@@ -7709,9 +8407,9 @@ var init_world_changes = __esm({
7709
8407
  this.seq += 1;
7710
8408
  return `snap_${Date.now().toString(36)}_${this.seq}`;
7711
8409
  }
7712
- put(path7, fingerprint) {
8410
+ put(path9, fingerprint) {
7713
8411
  const id = this.newId();
7714
- this.snapshots.set(id, { id, path: path7, fingerprint, createdAt: Date.now() });
8412
+ this.snapshots.set(id, { id, path: path9, fingerprint, createdAt: Date.now() });
7715
8413
  this.prune();
7716
8414
  return id;
7717
8415
  }
@@ -8771,7 +9469,7 @@ function encodeImageFromRgbaResponse(response, format, quality) {
8771
9469
  };
8772
9470
  }
8773
9471
  function sleep(ms) {
8774
- return new Promise((resolve6) => setTimeout(resolve6, ms));
9472
+ return new Promise((resolve7) => setTimeout(resolve7, ms));
8775
9473
  }
8776
9474
  function errorMessage(error) {
8777
9475
  return error instanceof Error ? error.message : String(error);
@@ -9207,18 +9905,18 @@ var init_world_model_tools = __esm({
9207
9905
  constructor(runtime) {
9208
9906
  this.runtime = runtime;
9209
9907
  }
9210
- async getWorldSnapshot(path7, level, topNPerClass, instance_id) {
9211
- const code = buildWorldSnapshotLuau(path7 ?? "game", level ?? "overview", topNPerClass ?? 12);
9908
+ async getWorldSnapshot(path9, level, topNPerClass, instance_id) {
9909
+ const code = buildWorldSnapshotLuau(path9 ?? "game", level ?? "overview", topNPerClass ?? 12);
9212
9910
  const response = await this.runtime.callSingle("/api/execute-luau", { code }, "edit", instance_id);
9213
9911
  return wrapToolJsonText(normalizeExecuteLuauToolResult(response, {
9214
9912
  error: "get_world_snapshot returned non-object execute-luau output"
9215
9913
  }));
9216
9914
  }
9217
- async sceneSearch(query, path7, limit, instance_id) {
9915
+ async sceneSearch(query, path9, limit, instance_id) {
9218
9916
  if (!query || !query.trim()) {
9219
9917
  throw new Error("query is required for scene_search");
9220
9918
  }
9221
- const response = await this.runtime.callSingle("/api/execute-luau", { code: buildSceneSearchLuau(query, path7 ?? "game", limit ?? 10) }, "edit", instance_id);
9919
+ const response = await this.runtime.callSingle("/api/execute-luau", { code: buildSceneSearchLuau(query, path9 ?? "game", limit ?? 10) }, "edit", instance_id);
9222
9920
  return wrapToolJsonText(normalizeExecuteLuauToolResult(response, {
9223
9921
  query,
9224
9922
  total: 0,
@@ -9238,8 +9936,8 @@ var init_world_model_tools = __esm({
9238
9936
  error: "get_node_batch returned non-object execute-luau output"
9239
9937
  }));
9240
9938
  }
9241
- async _captureFingerprint(path7, instance_id) {
9242
- const response = await this.runtime.callSingle("/api/execute-luau", { code: buildWorldFingerprintLuau(path7) }, "edit", instance_id);
9939
+ async _captureFingerprint(path9, instance_id) {
9940
+ const response = await this.runtime.callSingle("/api/execute-luau", { code: buildWorldFingerprintLuau(path9) }, "edit", instance_id);
9243
9941
  try {
9244
9942
  const rv = response?.returnValue;
9245
9943
  if (typeof rv === "string") {
@@ -9252,8 +9950,8 @@ var init_world_model_tools = __esm({
9252
9950
  }
9253
9951
  return { fp: {}, count: 0, truncated: false, error: "Could not parse world fingerprint" };
9254
9952
  }
9255
- async getChangesSince(snapshotId, path7, instance_id) {
9256
- const p = path7 ?? "game";
9953
+ async getChangesSince(snapshotId, path9, instance_id) {
9954
+ const p = path9 ?? "game";
9257
9955
  const cur = await this._captureFingerprint(p, instance_id);
9258
9956
  const wrap4 = (obj) => ({ content: [{ type: "text", text: JSON.stringify(obj) }] });
9259
9957
  if (cur.error)
@@ -9417,8 +10115,8 @@ var init_response_shape = __esm({
9417
10115
  });
9418
10116
 
9419
10117
  // ../core/dist/builders/scene-summary.js
9420
- function buildSceneSummaryLuau(path7 = "game.Workspace", topN = 20) {
9421
- const safePath = luaString(path7);
10118
+ function buildSceneSummaryLuau(path9 = "game.Workspace", topN = 20) {
10119
+ const safePath = luaString(path9);
9422
10120
  const safeTopN = luaNumber(Math.max(1, Math.floor(topN)));
9423
10121
  return `${PATH_RESOLVER_LUA}
9424
10122
  local root = resolvePath(${safePath})
@@ -9467,8 +10165,8 @@ var init_scene_read_tools = __esm({
9467
10165
  constructor(runtime) {
9468
10166
  this.runtime = runtime;
9469
10167
  }
9470
- async getFileTree(path7 = "", instance_id) {
9471
- const response = await this.runtime.callSingle("/api/file-tree", { path: path7 }, void 0, instance_id);
10168
+ async getFileTree(path9 = "", instance_id) {
10169
+ const response = await this.runtime.callSingle("/api/file-tree", { path: path9 }, void 0, instance_id);
9472
10170
  return compactText(response);
9473
10171
  }
9474
10172
  async getPlaceInfo(instance_id) {
@@ -9522,9 +10220,9 @@ var init_scene_read_tools = __esm({
9522
10220
  const response = await this.runtime.callSingle("/api/class-info", { className }, void 0, instance_id);
9523
10221
  return { content: [{ type: "text", text: JSON.stringify(response) }] };
9524
10222
  }
9525
- async getProjectStructure(path7, maxDepth, scriptsOnly, instance_id) {
10223
+ async getProjectStructure(path9, maxDepth, scriptsOnly, instance_id) {
9526
10224
  const response = await this.runtime.callSingle("/api/project-structure", {
9527
- path: path7,
10225
+ path: path9,
9528
10226
  maxDepth,
9529
10227
  scriptsOnly
9530
10228
  }, void 0, instance_id);
@@ -9820,13 +10518,14 @@ ${JSON.stringify({ errors: result.errors, warnings: result.warnings })}`
9820
10518
  });
9821
10519
 
9822
10520
  // ../core/dist/builders/mutation-plan.js
9823
- function buildMutationPlanLuau(operations, dryRun) {
10521
+ function buildMutationPlanLuau(operations, dryRun, atomic = true) {
9824
10522
  const opsJson = JSON.stringify(JSON.stringify(operations));
9825
10523
  return `${PATH_RESOLVER_LUA}
9826
10524
  local HttpService = game:GetService("HttpService")
9827
10525
  local CollectionService = game:GetService("CollectionService")
9828
10526
  local ops = HttpService:JSONDecode(${opsJson})
9829
10527
  local dryRun = ${luaBool(dryRun)}
10528
+ local atomic = ${luaBool(atomic)}
9830
10529
 
9831
10530
  local function ser(v)
9832
10531
  local t = typeof(v)
@@ -9837,8 +10536,26 @@ end
9837
10536
  local results = {}
9838
10537
  local rollback = {}
9839
10538
  local succeeded, failed = 0, 0
10539
+ local conflicts = {}
10540
+
10541
+ for index, op in ipairs(ops) do
10542
+ if _G.__mcp and _G.__mcp.checkCancelled and _G.__mcp.checkCancelled() then return { applied = false, cancelled = true } end
10543
+ if op.expected ~= nil then
10544
+ local inst = resolvePath(op.target)
10545
+ local current
10546
+ if inst and op.op == "set_property" then current = ser(inst[op.property])
10547
+ elseif inst and op.op == "set_attribute" then current = ser(inst:GetAttribute(op.name))
10548
+ elseif inst and (op.op == "add_tag" or op.op == "remove_tag") then current = CollectionService:HasTag(inst, op.tag) end
10549
+ if current ~= op.expected then table.insert(conflicts, { index = index, target = op.target, expected = op.expected, actual = current }) end
10550
+ end
10551
+ end
10552
+
10553
+ if #conflicts > 0 then
10554
+ return { applied = false, conflict = true, conflicts = conflicts, results = {}, rollback = {}, summary = { total = #ops, succeeded = 0, failed = 0 } }
10555
+ end
9840
10556
 
9841
10557
  for _, op in ipairs(ops) do
10558
+ if _G.__mcp and _G.__mcp.checkCancelled and _G.__mcp.checkCancelled() then return { applied = false, cancelled = true, rollback = rollback } end
9842
10559
  local r = { op = op.op, target = op.target }
9843
10560
  local inst = resolvePath(op.target)
9844
10561
  if not inst then
@@ -9895,9 +10612,26 @@ for _, op in ipairs(ops) do
9895
10612
  table.insert(results, r)
9896
10613
  end
9897
10614
 
10615
+ local rolledBack = false
10616
+ if atomic and not dryRun and failed > 0 then
10617
+ rolledBack = true
10618
+ for i = #rollback, 1, -1 do
10619
+ local op = rollback[i]
10620
+ local inst = resolvePath(op.target)
10621
+ if inst then pcall(function()
10622
+ if op.op == "set_property" then inst[op.property] = op.value
10623
+ elseif op.op == "set_attribute" then inst:SetAttribute(op.name, op.value)
10624
+ elseif op.op == "add_tag" then CollectionService:AddTag(inst, op.tag)
10625
+ elseif op.op == "remove_tag" then CollectionService:RemoveTag(inst, op.tag) end
10626
+ end) end
10627
+ end
10628
+ end
10629
+
9898
10630
  return {
9899
- applied = not dryRun,
10631
+ applied = not dryRun and not rolledBack,
9900
10632
  dryRun = dryRun,
10633
+ atomic = atomic,
10634
+ rolledBack = rolledBack,
9901
10635
  results = results,
9902
10636
  rollback = rollback,
9903
10637
  summary = { total = #ops, succeeded = succeeded, failed = failed },
@@ -10059,7 +10793,7 @@ var init_mutation_tools = __esm({
10059
10793
  }
10060
10794
  // Transactional batch mutations: apply many small edits in one round-trip with a
10061
10795
  // dry-run diff and a ready-to-run reverse plan in the receipt (stateless rollback).
10062
- async applyMutationPlan(operations, dryRun, confirm, instance_id) {
10796
+ async applyMutationPlan(operations, dryRun, confirm, instance_id, atomic = true) {
10063
10797
  if (!Array.isArray(operations) || operations.length === 0) {
10064
10798
  throw new Error("operations (a non-empty array) is required for apply_mutation_plan");
10065
10799
  }
@@ -10068,7 +10802,7 @@ var init_mutation_tools = __esm({
10068
10802
  if (gated)
10069
10803
  return gated;
10070
10804
  }
10071
- const response = await this.runtime.callSingle("/api/execute-luau", { code: buildMutationPlanLuau(operations, !!dryRun) }, "edit", instance_id);
10805
+ const response = await this.runtime.callSingle("/api/execute-luau", { code: buildMutationPlanLuau(operations, !!dryRun, atomic) }, "edit", instance_id);
10072
10806
  if (!dryRun)
10073
10807
  this.runtime.recordOperation("bulk_mutate", `mutation plan: ${operations.length} ops`);
10074
10808
  return wrapToolJsonText(normalizeExecuteLuauToolResult(response, {
@@ -10578,9 +11312,9 @@ var init_image_client = __esm({
10578
11312
  });
10579
11313
 
10580
11314
  // ../core/dist/tools/asset-tools.js
10581
- import * as fs from "fs";
10582
- import * as os from "os";
10583
- import * as path from "path";
11315
+ import * as fs2 from "fs";
11316
+ import * as os2 from "os";
11317
+ import * as path2 from "path";
10584
11318
  var AssetTools;
10585
11319
  var init_asset_tools = __esm({
10586
11320
  "../core/dist/tools/asset-tools.js"() {
@@ -10596,13 +11330,13 @@ var init_asset_tools = __esm({
10596
11330
  }
10597
11331
  // ─── Static helpers (library path) ────────────────────────────────
10598
11332
  static findProjectRoot(startDir) {
10599
- let dir = path.resolve(startDir);
11333
+ let dir = path2.resolve(startDir);
10600
11334
  let previous = "";
10601
11335
  while (dir !== previous) {
10602
- if (fs.existsSync(path.join(dir, ".git")) || fs.existsSync(path.join(dir, "package.json")))
11336
+ if (fs2.existsSync(path2.join(dir, ".git")) || fs2.existsSync(path2.join(dir, "package.json")))
10603
11337
  return dir;
10604
11338
  previous = dir;
10605
- dir = path.dirname(dir);
11339
+ dir = path2.dirname(dir);
10606
11340
  }
10607
11341
  return null;
10608
11342
  }
@@ -10610,22 +11344,22 @@ var init_asset_tools = __esm({
10610
11344
  if (!candidate)
10611
11345
  return false;
10612
11346
  try {
10613
- return fs.statSync(candidate).isDirectory();
11347
+ return fs2.statSync(candidate).isDirectory();
10614
11348
  } catch {
10615
11349
  return false;
10616
11350
  }
10617
11351
  }
10618
11352
  static ensureWritableDirectory(candidate, label) {
10619
- const resolved = path.resolve(candidate);
11353
+ const resolved = path2.resolve(candidate);
10620
11354
  try {
10621
- fs.mkdirSync(resolved, { recursive: true });
11355
+ fs2.mkdirSync(resolved, { recursive: true });
10622
11356
  } catch (error) {
10623
11357
  throw new Error(`Unable to create ${label} build-library directory at ${resolved}: ${error.message}`);
10624
11358
  }
10625
11359
  if (!_AssetTools.isDirectory(resolved))
10626
11360
  throw new Error(`${label} build-library path is not a directory: ${resolved}`);
10627
11361
  try {
10628
- fs.accessSync(resolved, fs.constants.W_OK);
11362
+ fs2.accessSync(resolved, fs2.constants.W_OK);
10629
11363
  } catch (error) {
10630
11364
  throw new Error(`${label} build-library directory is not writable: ${resolved}. ${error.message}`);
10631
11365
  }
@@ -10636,27 +11370,27 @@ var init_asset_tools = __esm({
10636
11370
  if (_AssetTools._cachedLibraryPath)
10637
11371
  return _AssetTools._cachedLibraryPath;
10638
11372
  const overridePath = process.env.BLOXFORGE_BUILD_LIBRARY || process.env.ROBLOXSTUDIO_MCP_BUILD_LIBRARY || process.env.BUILD_LIBRARY_PATH;
10639
- const cwd = path.resolve(process.cwd());
11373
+ const cwd = path2.resolve(process.cwd());
10640
11374
  const projectRoot = _AssetTools.findProjectRoot(cwd);
10641
- const newHomeLibraryPath = path.join(os.homedir(), ".bloxforge", "build-library");
10642
- const legacyHomeLibraryPath = path.join(os.homedir(), ".robloxstudio-mcp", "build-library");
10643
- const homeLibraryPath = fs.existsSync(newHomeLibraryPath) ? newHomeLibraryPath : fs.existsSync(legacyHomeLibraryPath) ? legacyHomeLibraryPath : newHomeLibraryPath;
10644
- const projectLibraryPath = projectRoot ? path.join(projectRoot, "build-library") : null;
10645
- const cwdLibraryPath = path.join(cwd, "build-library");
11375
+ const newHomeLibraryPath = path2.join(os2.homedir(), ".bloxforge", "build-library");
11376
+ const legacyHomeLibraryPath = path2.join(os2.homedir(), ".robloxstudio-mcp", "build-library");
11377
+ const homeLibraryPath = fs2.existsSync(newHomeLibraryPath) ? newHomeLibraryPath : fs2.existsSync(legacyHomeLibraryPath) ? legacyHomeLibraryPath : newHomeLibraryPath;
11378
+ const projectLibraryPath = projectRoot ? path2.join(projectRoot, "build-library") : null;
11379
+ const cwdLibraryPath = path2.join(cwd, "build-library");
10646
11380
  let result;
10647
11381
  if (overridePath) {
10648
11382
  result = _AssetTools.ensureWritableDirectory(overridePath, "override");
10649
11383
  } else {
10650
11384
  const existing = [projectLibraryPath, cwdLibraryPath].find((c) => c && _AssetTools.isDirectory(c) && (() => {
10651
11385
  try {
10652
- fs.accessSync(c, fs.constants.W_OK);
11386
+ fs2.accessSync(c, fs2.constants.W_OK);
10653
11387
  return true;
10654
11388
  } catch {
10655
11389
  return false;
10656
11390
  }
10657
11391
  })());
10658
11392
  if (existing) {
10659
- result = path.resolve(existing);
11393
+ result = path2.resolve(existing);
10660
11394
  } else if (projectLibraryPath) {
10661
11395
  try {
10662
11396
  result = _AssetTools.ensureWritableDirectory(projectLibraryPath, "project-root");
@@ -10777,11 +11511,11 @@ var init_asset_tools = __esm({
10777
11511
  if (response && response.success && response.buildData) {
10778
11512
  const buildData = response.buildData;
10779
11513
  const buildId = buildData.id || `${style}/exported`;
10780
- const filePath = path.join(_AssetTools.findLibraryPath(), `${buildId}.json`);
10781
- const dirPath = path.dirname(filePath);
10782
- if (!fs.existsSync(dirPath))
10783
- fs.mkdirSync(dirPath, { recursive: true });
10784
- fs.writeFileSync(filePath, JSON.stringify(buildData, null, 2));
11514
+ const filePath = path2.join(_AssetTools.findLibraryPath(), `${buildId}.json`);
11515
+ const dirPath = path2.dirname(filePath);
11516
+ if (!fs2.existsSync(dirPath))
11517
+ fs2.mkdirSync(dirPath, { recursive: true });
11518
+ fs2.writeFileSync(filePath, JSON.stringify(buildData, null, 2));
10785
11519
  response.savedTo = filePath;
10786
11520
  }
10787
11521
  return { content: [{ type: "text", text: JSON.stringify(response) }] };
@@ -10793,11 +11527,11 @@ var init_asset_tools = __esm({
10793
11527
  const normalizedParts = this.normalizeBuildParts(parts, new Set(Object.keys(normalizedPalette)));
10794
11528
  const computedBounds = bounds || this.computeBounds(normalizedParts);
10795
11529
  const buildData = { id, style, bounds: computedBounds, palette: normalizedPalette, parts: normalizedParts };
10796
- const filePath = path.join(_AssetTools.findLibraryPath(), `${id}.json`);
10797
- const dirPath = path.dirname(filePath);
10798
- if (!fs.existsSync(dirPath))
10799
- fs.mkdirSync(dirPath, { recursive: true });
10800
- fs.writeFileSync(filePath, JSON.stringify(buildData, null, 2));
11530
+ const filePath = path2.join(_AssetTools.findLibraryPath(), `${id}.json`);
11531
+ const dirPath = path2.dirname(filePath);
11532
+ if (!fs2.existsSync(dirPath))
11533
+ fs2.mkdirSync(dirPath, { recursive: true });
11534
+ fs2.writeFileSync(filePath, JSON.stringify(buildData, null, 2));
10801
11535
  return {
10802
11536
  content: [{ type: "text", text: JSON.stringify({
10803
11537
  success: true,
@@ -10822,11 +11556,11 @@ var init_asset_tools = __esm({
10822
11556
  const buildData = { id, style, bounds: result.bounds, palette, parts: result.parts, generatorCode: code };
10823
11557
  if (seed !== void 0)
10824
11558
  buildData.generatorSeed = seed;
10825
- const filePath = path.join(_AssetTools.findLibraryPath(), `${id}.json`);
10826
- const dirPath = path.dirname(filePath);
10827
- if (!fs.existsSync(dirPath))
10828
- fs.mkdirSync(dirPath, { recursive: true });
10829
- fs.writeFileSync(filePath, JSON.stringify(buildData, null, 2));
11559
+ const filePath = path2.join(_AssetTools.findLibraryPath(), `${id}.json`);
11560
+ const dirPath = path2.dirname(filePath);
11561
+ if (!fs2.existsSync(dirPath))
11562
+ fs2.mkdirSync(dirPath, { recursive: true });
11563
+ fs2.writeFileSync(filePath, JSON.stringify(buildData, null, 2));
10830
11564
  return {
10831
11565
  content: [{ type: "text", text: JSON.stringify({
10832
11566
  success: true,
@@ -10840,11 +11574,11 @@ var init_asset_tools = __esm({
10840
11574
  }
10841
11575
  async importBuild(buildData, targetPath, position, instance_id) {
10842
11576
  if (typeof buildData === "string") {
10843
- const filePath = path.join(_AssetTools.findLibraryPath(), `${buildData}.json`);
10844
- if (!fs.existsSync(filePath)) {
11577
+ const filePath = path2.join(_AssetTools.findLibraryPath(), `${buildData}.json`);
11578
+ if (!fs2.existsSync(filePath)) {
10845
11579
  return toolErrorResult(`Build not found in library: ${buildData}`);
10846
11580
  }
10847
- buildData = JSON.parse(fs.readFileSync(filePath, "utf-8"));
11581
+ buildData = JSON.parse(fs2.readFileSync(filePath, "utf-8"));
10848
11582
  }
10849
11583
  const response = await this.runtime.callSingle("/api/import-build", {
10850
11584
  buildData,
@@ -10856,14 +11590,14 @@ var init_asset_tools = __esm({
10856
11590
  async listLibrary(style) {
10857
11591
  const libPath = _AssetTools.findLibraryPath();
10858
11592
  let entries = [];
10859
- if (fs.existsSync(libPath)) {
11593
+ if (fs2.existsSync(libPath)) {
10860
11594
  const readDir = (dir) => {
10861
- for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
11595
+ for (const entry of fs2.readdirSync(dir, { withFileTypes: true })) {
10862
11596
  if (entry.isDirectory()) {
10863
- readDir(path.join(dir, entry.name));
11597
+ readDir(path2.join(dir, entry.name));
10864
11598
  } else if (entry.name.endsWith(".json")) {
10865
11599
  try {
10866
- const data = JSON.parse(fs.readFileSync(path.join(dir, entry.name), "utf-8"));
11600
+ const data = JSON.parse(fs2.readFileSync(path2.join(dir, entry.name), "utf-8"));
10867
11601
  if (style && data.style !== style)
10868
11602
  continue;
10869
11603
  entries.push({
@@ -10888,10 +11622,10 @@ var init_asset_tools = __esm({
10888
11622
  return { content: [{ type: "text", text: JSON.stringify(response) }] };
10889
11623
  }
10890
11624
  async getBuild(id) {
10891
- const filePath = path.join(_AssetTools.findLibraryPath(), `${id}.json`);
10892
- if (!fs.existsSync(filePath))
11625
+ const filePath = path2.join(_AssetTools.findLibraryPath(), `${id}.json`);
11626
+ if (!fs2.existsSync(filePath))
10893
11627
  return toolErrorResult(`Build not found in library: ${id}`);
10894
- const data = JSON.parse(fs.readFileSync(filePath, "utf-8"));
11628
+ const data = JSON.parse(fs2.readFileSync(filePath, "utf-8"));
10895
11629
  return { content: [{ type: "text", text: JSON.stringify(data) }] };
10896
11630
  }
10897
11631
  async importScene(sceneData, targetPath, instance_id) {
@@ -10935,10 +11669,10 @@ var init_asset_tools = __esm({
10935
11669
  const buildId = models[modelKey];
10936
11670
  if (!buildId)
10937
11671
  throw new Error(`Unknown model key "${modelKey}" in scene placement`);
10938
- const filePath = path.join(_AssetTools.findLibraryPath(), `${buildId}.json`);
10939
- if (!fs.existsSync(filePath))
11672
+ const filePath = path2.join(_AssetTools.findLibraryPath(), `${buildId}.json`);
11673
+ if (!fs2.existsSync(filePath))
10940
11674
  throw new Error(`Build "${buildId}" not found in library`);
10941
- const buildData = JSON.parse(fs.readFileSync(filePath, "utf-8"));
11675
+ const buildData = JSON.parse(fs2.readFileSync(filePath, "utf-8"));
10942
11676
  await this.runtime.callSingle("/api/import-build", {
10943
11677
  buildData,
10944
11678
  targetPath: targetPath || "Workspace",
@@ -11104,10 +11838,10 @@ var init_asset_tools = __esm({
11104
11838
  return { content: [{ type: "text", text: JSON.stringify(response) }] };
11105
11839
  }
11106
11840
  async uploadAsset(filePath, assetType, displayName, description, userId, groupId) {
11107
- if (!fs.existsSync(filePath))
11841
+ if (!fs2.existsSync(filePath))
11108
11842
  throw new Error(`File not found: ${filePath}`);
11109
- const fileContent = fs.readFileSync(filePath);
11110
- const fileName = path.basename(filePath);
11843
+ const fileContent = fs2.readFileSync(filePath);
11844
+ const fileName = path2.basename(filePath);
11111
11845
  const cc = this.runtime.cookieClient;
11112
11846
  const oc = this.runtime.openCloudClient;
11113
11847
  if (assetType === "Decal" && cc.hasCookie()) {
@@ -11156,10 +11890,10 @@ var init_asset_tools = __esm({
11156
11890
  if (!response.base64)
11157
11891
  return toolErrorResult("plugin returned no base64 payload");
11158
11892
  const bytes = Buffer.from(response.base64, "base64");
11159
- const resolved = path.resolve(outputPath);
11893
+ const resolved = path2.resolve(outputPath);
11160
11894
  try {
11161
- fs.mkdirSync(path.dirname(resolved), { recursive: true });
11162
- fs.writeFileSync(resolved, bytes);
11895
+ fs2.mkdirSync(path2.dirname(resolved), { recursive: true });
11896
+ fs2.writeFileSync(resolved, bytes);
11163
11897
  } catch (err) {
11164
11898
  return toolErrorResult(`failed to write ${resolved}: ${err.message}`);
11165
11899
  }
@@ -11183,9 +11917,9 @@ var init_asset_tools = __esm({
11183
11917
  let bytes;
11184
11918
  let sourceLabel;
11185
11919
  if (source.path !== void 0) {
11186
- const resolved = path.resolve(source.path);
11920
+ const resolved = path2.resolve(source.path);
11187
11921
  try {
11188
- bytes = fs.readFileSync(resolved);
11922
+ bytes = fs2.readFileSync(resolved);
11189
11923
  } catch (err) {
11190
11924
  return toolErrorResult(`failed to read ${resolved}: ${err.message}`);
11191
11925
  }
@@ -11237,10 +11971,10 @@ var init_asset_tools = __esm({
11237
11971
  const { buffer, contentType } = await this.runtime.imageClient.generate(prompt, options ?? {});
11238
11972
  const ext = contentType.includes("png") ? "png" : "jpg";
11239
11973
  const slug = prompt.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40) || "image";
11240
- const dir = path.resolve(process.env.ROBLOX_IMAGE_DIR ?? path.join(process.cwd(), "generated-images"));
11241
- fs.mkdirSync(dir, { recursive: true });
11242
- const file = path.join(dir, `${slug}-${Date.now()}.${ext}`);
11243
- fs.writeFileSync(file, buffer);
11974
+ const dir = path2.resolve(process.env.ROBLOX_IMAGE_DIR ?? path2.join(process.cwd(), "generated-images"));
11975
+ fs2.mkdirSync(dir, { recursive: true });
11976
+ const file = path2.join(dir, `${slug}-${Date.now()}.${ext}`);
11977
+ fs2.writeFileSync(file, buffer);
11244
11978
  return { file, bytes: buffer.length, model: options?.model ?? DEFAULT_IMAGE_MODEL };
11245
11979
  }
11246
11980
  async imageGenerate(prompt, options) {
@@ -11750,8 +12484,8 @@ var init_episode_reasoning = __esm({
11750
12484
  });
11751
12485
 
11752
12486
  // ../core/dist/tools/runtime-tools.js
11753
- import * as fs2 from "fs";
11754
- import * as path2 from "path";
12487
+ import * as fs3 from "fs";
12488
+ import * as path3 from "path";
11755
12489
  function isEndTestAlreadyCalledError(value) {
11756
12490
  return /EndTest.*can only be called once|can only be called once/i.test(errorMessage(value));
11757
12491
  }
@@ -12636,9 +13370,9 @@ var init_runtime_tools = __esm({
12636
13370
  const rawJson = mutable.raw_json;
12637
13371
  if (typeof rawJson === "string") {
12638
13372
  if (typeof outputPath === "string" && outputPath !== "") {
12639
- const resolvedOutputPath = path2.resolve(outputPath);
12640
- fs2.mkdirSync(path2.dirname(resolvedOutputPath), { recursive: true });
12641
- fs2.writeFileSync(resolvedOutputPath, rawJson, "utf8");
13373
+ const resolvedOutputPath = path3.resolve(outputPath);
13374
+ fs3.mkdirSync(path3.dirname(resolvedOutputPath), { recursive: true });
13375
+ fs3.writeFileSync(resolvedOutputPath, rawJson, "utf8");
12642
13376
  mutable.output_path = resolvedOutputPath;
12643
13377
  }
12644
13378
  delete mutable.raw_json;
@@ -13351,6 +14085,7 @@ function buildCreateSoundLuau(options) {
13351
14085
  const lines = [
13352
14086
  `local parent = resolvePath(${luaString(options.parentPath)})`,
13353
14087
  `if parent == nil then error("Parent not found: " .. ${luaString(options.parentPath)}) end`,
14088
+ "if _G.__mcp and _G.__mcp.checkCancelled and _G.__mcp.checkCancelled() then return { cancelled = true } end",
13354
14089
  `local sound = Instance.new("Sound")`,
13355
14090
  `sound.SoundId = ${luaString(assetUri(options.soundId))}`
13356
14091
  ];
@@ -13371,6 +14106,7 @@ function buildCreateSoundLuau(options) {
13371
14106
  function buildPlaySoundLuau(options) {
13372
14107
  const body = `local sound = resolvePath(${luaString(options.path)})
13373
14108
  if sound == nil or not sound:IsA("Sound") then error("Sound not found: " .. ${luaString(options.path)}) end
14109
+ if _G.__mcp and _G.__mcp.checkCancelled and _G.__mcp.checkCancelled() then return { cancelled = true } end
13374
14110
  sound:Play()
13375
14111
  return { path = sound:GetFullName(), playing = true, success = true }`;
13376
14112
  return wrap(body);
@@ -13379,6 +14115,7 @@ function buildCreateAnimationLuau(options) {
13379
14115
  const lines = [
13380
14116
  `local parent = resolvePath(${luaString(options.parentPath)})`,
13381
14117
  `if parent == nil then error("Parent not found: " .. ${luaString(options.parentPath)}) end`,
14118
+ "if _G.__mcp and _G.__mcp.checkCancelled and _G.__mcp.checkCancelled() then return { cancelled = true } end",
13382
14119
  `local anim = Instance.new("Animation")`,
13383
14120
  `anim.AnimationId = ${luaString(assetUri(options.animationId))}`,
13384
14121
  `anim.Name = ${luaString(options.name ?? "Animation")}`,
@@ -13397,6 +14134,7 @@ if animator == nil then
13397
14134
  animator = Instance.new("Animator")
13398
14135
  animator.Parent = controller
13399
14136
  end
14137
+ if _G.__mcp and _G.__mcp.checkCancelled and _G.__mcp.checkCancelled() then return { cancelled = true } end
13400
14138
  local anim = Instance.new("Animation")
13401
14139
  anim.AnimationId = ${luaString(assetUri(options.animationId))}
13402
14140
  local track = animator:LoadAnimation(anim)
@@ -13410,6 +14148,7 @@ function buildApplyTextureLuau(options) {
13410
14148
  if (options.property) {
13411
14149
  const body2 = `local target = resolvePath(${luaString(options.targetPath)})
13412
14150
  if target == nil then error("Target not found: " .. ${luaString(options.targetPath)}) end
14151
+ if _G.__mcp and _G.__mcp.checkCancelled and _G.__mcp.checkCancelled() then return { cancelled = true } end
13413
14152
  target.${options.property.replace(/[^A-Za-z0-9]/g, "")} = ${uri}
13414
14153
  return { path = target:GetFullName(), property = ${luaString(options.property)}, success = true }`;
13415
14154
  return wrap(body2);
@@ -13417,6 +14156,7 @@ return { path = target:GetFullName(), property = ${luaString(options.property)},
13417
14156
  const body = `local target = resolvePath(${luaString(options.targetPath)})
13418
14157
  if target == nil then error("Target not found: " .. ${luaString(options.targetPath)}) end
13419
14158
  local uri = ${uri}
14159
+ if _G.__mcp and _G.__mcp.checkCancelled and _G.__mcp.checkCancelled() then return { cancelled = true } end
13420
14160
  local prop
13421
14161
  if target:IsA("ImageLabel") or target:IsA("ImageButton") then
13422
14162
  prop = "Image"
@@ -13456,6 +14196,7 @@ local parent = resolvePath(${luaString(parentPath)})
13456
14196
  if parent == nil then error("Parent not found: " .. ${luaString(parentPath)}) end
13457
14197
  local inputs = { ${inputs.join(", ")} }
13458
14198
  local schema = ${schemaLua}
14199
+ if _G.__mcp and _G.__mcp.checkCancelled and _G.__mcp.checkCancelled() then return { cancelled = true } end
13459
14200
  local model, meta = GenerationService:GenerateModelAsync(inputs, schema)
13460
14201
  if model == nil then error("GenerateModelAsync returned no model") end
13461
14202
  local desiredName = ${nameLua}
@@ -13520,6 +14261,7 @@ local function ensureCorner(o)
13520
14261
  end
13521
14262
  end
13522
14263
  for _, o in ipairs(root:GetDescendants()) do
14264
+ if _G.__mcp and _G.__mcp.checkCancelled and _G.__mcp.checkCancelled() then return { cancelled = true } end
13523
14265
  if o:IsA("GuiButton") then
13524
14266
  o.BackgroundColor3 = PRIMARY
13525
14267
  o.BorderSizePixel = 0
@@ -13600,6 +14342,7 @@ end
13600
14342
  local function lintRoot(root)
13601
14343
  local interactives = {}
13602
14344
  for _, o in ipairs(root:GetDescendants()) do
14345
+ if _G.__mcp and _G.__mcp.checkCancelled and _G.__mcp.checkCancelled() then return { cancelled = true } end
13603
14346
  if o:IsA("GuiObject") then
13604
14347
  if (o:IsA("TextLabel") or o:IsA("TextButton") or o:IsA("TextBox")) and not o.TextScaled and o.TextSize < MIN_TEXT_SIZE then
13605
14348
  add("tiny_text", "warn", o, string.format("TextSize %d < %d; hard to read \u2014 raise it or use TextScaled + UITextSizeConstraint", o.TextSize, MIN_TEXT_SIZE))
@@ -13734,6 +14477,7 @@ function buildScreenGuiLuau(options) {
13734
14477
  const parentPath = options.parentPath ?? "StarterGui";
13735
14478
  const lines = [
13736
14479
  `local parent = resolvePath(${luaString(parentPath)}) or game:GetService("StarterGui")`,
14480
+ "if _G.__mcp and _G.__mcp.checkCancelled and _G.__mcp.checkCancelled() then return { cancelled = true } end",
13737
14481
  `local obj = Instance.new("ScreenGui")`,
13738
14482
  `obj.Name = ${luaString(options.name)}`
13739
14483
  ];
@@ -13751,6 +14495,7 @@ function buildGuiObjectLuau(className, options) {
13751
14495
  const lines = [
13752
14496
  `local parent = resolvePath(${luaString(options.parentPath)})`,
13753
14497
  `if parent == nil then error("Parent not found: " .. ${luaString(options.parentPath)}) end`,
14498
+ "if _G.__mcp and _G.__mcp.checkCancelled and _G.__mcp.checkCancelled() then return { cancelled = true } end",
13754
14499
  `local obj = Instance.new(${luaString(className)})`
13755
14500
  ];
13756
14501
  if (options.name !== void 0)
@@ -13793,6 +14538,7 @@ function buildApplyLayoutLuau(targetPath, options) {
13793
14538
  `local target = resolvePath(${luaString(targetPath)})`,
13794
14539
  `if target == nil then error("Target not found: " .. ${luaString(targetPath)}) end`
13795
14540
  ];
14541
+ lines.push("if _G.__mcp and _G.__mcp.checkCancelled and _G.__mcp.checkCancelled() then return { cancelled = true } end");
13796
14542
  if (options.layout === "grid") {
13797
14543
  lines.push(`local layout = Instance.new("UIGridLayout")`);
13798
14544
  if (options.cellSize)
@@ -13826,6 +14572,7 @@ local function ensureScale(gui)
13826
14572
  scale.Parent = gui
13827
14573
  end
13828
14574
  end
14575
+ if _G.__mcp and _G.__mcp.checkCancelled and _G.__mcp.checkCancelled() then return { cancelled = true } end
13829
14576
  local descendants = target:GetDescendants()
13830
14577
  table.insert(descendants, target)
13831
14578
  for _, child in ipairs(descendants) do
@@ -13858,7 +14605,10 @@ function clampClock(hour) {
13858
14605
  return Math.max(0, Math.min(24, hour));
13859
14606
  }
13860
14607
  function buildSetTimeOfDayLuau(time) {
13861
- const lines = ['local Lighting = game:GetService("Lighting")'];
14608
+ const lines = [
14609
+ 'local Lighting = game:GetService("Lighting")',
14610
+ "if _G.__mcp and _G.__mcp.checkCancelled and _G.__mcp.checkCancelled() then return { cancelled = true } end"
14611
+ ];
13862
14612
  if (typeof time === "number") {
13863
14613
  lines.push(`Lighting.ClockTime = ${luaNumber(clampClock(time))}`);
13864
14614
  } else {
@@ -13908,6 +14658,7 @@ function buildLightingPresetLuau(preset, withPostFx = false) {
13908
14658
  }
13909
14659
  const lines = [
13910
14660
  'local Lighting = game:GetService("Lighting")',
14661
+ "if _G.__mcp and _G.__mcp.checkCancelled and _G.__mcp.checkCancelled() then return { cancelled = true } end",
13911
14662
  `Lighting.ClockTime = ${luaNumber(config.clockTime)}`,
13912
14663
  `Lighting.Ambient = ${color3FromRGB(...config.ambient)}`,
13913
14664
  `Lighting.OutdoorAmbient = ${color3FromRGB(...config.outdoorAmbient)}`,
@@ -13929,7 +14680,10 @@ function buildLightingPresetLuau(preset, withPostFx = false) {
13929
14680
  return lines.join("\n");
13930
14681
  }
13931
14682
  function buildAtmosphereLuau(options) {
13932
- const lines = ['local Lighting = game:GetService("Lighting")'];
14683
+ const lines = [
14684
+ 'local Lighting = game:GetService("Lighting")',
14685
+ "if _G.__mcp and _G.__mcp.checkCancelled and _G.__mcp.checkCancelled() then return { cancelled = true } end"
14686
+ ];
13933
14687
  lines.push(...atmosphereLines("Lighting", options));
13934
14688
  lines.push("return { success = true }");
13935
14689
  return lines.join("\n");
@@ -13937,6 +14691,7 @@ function buildAtmosphereLuau(options) {
13937
14691
  function buildSkyLuau(options) {
13938
14692
  const lines = [
13939
14693
  'local Lighting = game:GetService("Lighting")',
14694
+ "if _G.__mcp and _G.__mcp.checkCancelled and _G.__mcp.checkCancelled() then return { cancelled = true } end",
13940
14695
  'local sky = Lighting:FindFirstChildOfClass("Sky") or Instance.new("Sky")'
13941
14696
  ];
13942
14697
  if (options.sunTextureId !== void 0)
@@ -13970,6 +14725,7 @@ RunService.Heartbeat:Connect(function(dt)
13970
14725
  end)`;
13971
14726
  const lines = [
13972
14727
  'local ServerScriptService = game:GetService("ServerScriptService")',
14728
+ "if _G.__mcp and _G.__mcp.checkCancelled and _G.__mcp.checkCancelled() then return { cancelled = true } end",
13973
14729
  `local existing = ServerScriptService:FindFirstChild(${luaString(scriptName)})`,
13974
14730
  "if existing then existing:Destroy() end",
13975
14731
  'local script = Instance.new("Script")',
@@ -14082,6 +14838,7 @@ function buildBaseplateLuau(options) {
14082
14838
  const mat = material(options.material ?? "Grass");
14083
14839
  return [
14084
14840
  "local Terrain = workspace.Terrain",
14841
+ "if _G.__mcp and _G.__mcp.checkCancelled and _G.__mcp.checkCancelled() then return { cancelled = true } end",
14085
14842
  `Terrain:FillBlock(CFrame.new(${vector3(...pos)}), ${vector3(...options.size)}, ${mat})`,
14086
14843
  `return { shape = "baseplate", volume = ${luaNumber(boxVolume(options.size))}, success = true }`
14087
14844
  ].join("\n");
@@ -14090,6 +14847,7 @@ function buildIslandLuau(options) {
14090
14847
  const mat = material(options.material ?? "Sand");
14091
14848
  const lines = [
14092
14849
  "local Terrain = workspace.Terrain",
14850
+ "if _G.__mcp and _G.__mcp.checkCancelled and _G.__mcp.checkCancelled() then return { cancelled = true } end",
14093
14851
  `Terrain:FillBall(${vector3(...options.center)}, ${luaNumber(options.radius)}, ${mat})`
14094
14852
  ];
14095
14853
  if (options.waterMaterial || options.waterRadius) {
@@ -14114,7 +14872,9 @@ function buildMountainsLuau(options) {
14114
14872
  `local maxHeight = ${luaNumber(options.maxHeight)}`,
14115
14873
  `local seed = ${luaNumber(seed)}`,
14116
14874
  `local baseX, baseY, baseZ = ${luaNumber(cx - ex / 2)}, ${luaNumber(cy)}, ${luaNumber(cz - ez / 2)}`,
14117
- `for gx = 0, ${luaNumber(ex)}, res do`,
14875
+ `for gx = 0, ${luaNumber(ex)}, res do
14876
+ if _G.__mcp and _G.__mcp.checkCancelled and _G.__mcp.checkCancelled() then return { cancelled = true } end`,
14877
+ " if _G.__mcp and _G.__mcp.checkCancelled and _G.__mcp.checkCancelled() then return { cancelled = true } end",
14118
14878
  ` for gz = 0, ${luaNumber(ez)}, res do`,
14119
14879
  " local wx = baseX + gx",
14120
14880
  " local wz = baseZ + gz",
@@ -14130,6 +14890,7 @@ function buildWaterLuau(options) {
14130
14890
  const pos = options.position ?? [0, 0, 0];
14131
14891
  return [
14132
14892
  "local Terrain = workspace.Terrain",
14893
+ "if _G.__mcp and _G.__mcp.checkCancelled and _G.__mcp.checkCancelled() then return { cancelled = true } end",
14133
14894
  `Terrain:FillBlock(CFrame.new(${vector3(...pos)}), ${vector3(...options.size)}, Enum.Material.Water)`,
14134
14895
  `return { shape = "water", volume = ${luaNumber(boxVolume(options.size))}, success = true }`
14135
14896
  ].join("\n");
@@ -14140,6 +14901,7 @@ function buildPaintMaterialLuau(options) {
14140
14901
  const op = options.replaceMaterial ? `Terrain:ReplaceMaterial(${region}, 4, ${material(options.replaceMaterial)}, ${target})` : `Terrain:FillRegion(${region}, 4, ${target})`;
14141
14902
  return [
14142
14903
  "local Terrain = workspace.Terrain",
14904
+ "if _G.__mcp and _G.__mcp.checkCancelled and _G.__mcp.checkCancelled() then return { cancelled = true } end",
14143
14905
  op,
14144
14906
  'return { shape = "paint", success = true }'
14145
14907
  ].join("\n");
@@ -14147,6 +14909,7 @@ function buildPaintMaterialLuau(options) {
14147
14909
  function buildClearRegionLuau(options) {
14148
14910
  return [
14149
14911
  "local Terrain = workspace.Terrain",
14912
+ "if _G.__mcp and _G.__mcp.checkCancelled and _G.__mcp.checkCancelled() then return { cancelled = true } end",
14150
14913
  `Terrain:FillRegion(${region3(options.min, options.max)}, 4, Enum.Material.Air)`,
14151
14914
  'return { shape = "clear", success = true }'
14152
14915
  ].join("\n");
@@ -14227,6 +14990,8 @@ spawn.Anchored = true
14227
14990
 
14228
14991
  local checkpoints = ensure(course, "Checkpoints", "Folder")
14229
14992
  for i = 0, NUM_CHECKPOINTS do
14993
+ if _G.__mcp and _G.__mcp.checkCancelled and _G.__mcp.checkCancelled() then return { cancelled = true } end
14994
+ if _G.__mcp and _G.__mcp.checkCancelled and _G.__mcp.checkCancelled() then return { cancelled = true } end
14230
14995
  local cp = makePart(checkpoints, "Checkpoint" .. i, Vector3.new(10, 1, 10), Vector3.new(i * 40, 5, 0), Color3.fromRGB(80, 200, 120), true)
14231
14996
  cp:SetAttribute("Stage", i)
14232
14997
  if i > 0 then
@@ -14453,6 +15218,8 @@ local arena = ensure(Workspace, "Arena", "Model")
14453
15218
  makePart(arena, "ArenaFloor", Vector3.new(120, 1, 120), Vector3.new(300, 5, 0), Color3.fromRGB(110, 110, 120), true)
14454
15219
  local teleports = ensure(arena, "TeleportPoints", "Folder")
14455
15220
  for i = 1, NUM_TELEPORTS do
15221
+ if _G.__mcp and _G.__mcp.checkCancelled and _G.__mcp.checkCancelled() then return { cancelled = true } end
15222
+ if _G.__mcp and _G.__mcp.checkCancelled and _G.__mcp.checkCancelled() then return { cancelled = true } end
14456
15223
  local angle = (i / NUM_TELEPORTS) * math.pi * 2
14457
15224
  local tp = makePart(teleports, "Teleport" .. i, Vector3.new(6, 1, 6), Vector3.new(300 + math.cos(angle) * 40, 6, math.sin(angle) * 40), Color3.fromRGB(120, 120, 255), true)
14458
15225
  tp.Transparency = 0.5
@@ -14719,8 +15486,8 @@ var init_sync_luau = __esm({
14719
15486
  });
14720
15487
 
14721
15488
  // ../core/dist/tools/sync-tools.js
14722
- import * as fs3 from "fs";
14723
- import * as path3 from "path";
15489
+ import * as fs4 from "fs";
15490
+ import * as path4 from "path";
14724
15491
  var SyncTools;
14725
15492
  var init_sync_tools = __esm({
14726
15493
  "../core/dist/tools/sync-tools.js"() {
@@ -14740,11 +15507,11 @@ var init_sync_tools = __esm({
14740
15507
  // clobbering. SyncManager owns the (tested) path/conflict logic; this layer
14741
15508
  // owns filesystem and Studio I/O.
14742
15509
  _syncManifestPath(dir) {
14743
- return path3.join(dir, ".robloxsync.json");
15510
+ return path4.join(dir, ".robloxsync.json");
14744
15511
  }
14745
15512
  _readManifest(dir) {
14746
15513
  try {
14747
- const raw = fs3.readFileSync(this._syncManifestPath(dir), "utf8");
15514
+ const raw = fs4.readFileSync(this._syncManifestPath(dir), "utf8");
14748
15515
  const parsed = JSON.parse(raw);
14749
15516
  return parsed && typeof parsed.paths === "object" ? parsed.paths : {};
14750
15517
  } catch {
@@ -14753,7 +15520,7 @@ var init_sync_tools = __esm({
14753
15520
  }
14754
15521
  _writeManifest(dir, paths) {
14755
15522
  const payload = { version: 1, updatedAt: (/* @__PURE__ */ new Date()).toISOString(), paths };
14756
- fs3.writeFileSync(this._syncManifestPath(dir), JSON.stringify(payload, null, 2));
15523
+ fs4.writeFileSync(this._syncManifestPath(dir), JSON.stringify(payload, null, 2));
14757
15524
  }
14758
15525
  async _dumpStudioScripts(instance_id) {
14759
15526
  const response = await this.runtime.callSingle("/api/execute-luau", { code: buildDumpScriptsLuau() }, "edit", instance_id);
@@ -14776,17 +15543,17 @@ var init_sync_tools = __esm({
14776
15543
  const walk = (current) => {
14777
15544
  let entries;
14778
15545
  try {
14779
- entries = fs3.readdirSync(current, { withFileTypes: true });
15546
+ entries = fs4.readdirSync(current, { withFileTypes: true });
14780
15547
  } catch {
14781
15548
  return;
14782
15549
  }
14783
15550
  for (const entry of entries) {
14784
- const full = path3.join(current, entry.name);
14785
- const rel = path3.relative(dir, full).split(path3.sep).join("/");
15551
+ const full = path4.join(current, entry.name);
15552
+ const rel = path4.relative(dir, full).split(path4.sep).join("/");
14786
15553
  if (entry.isDirectory()) {
14787
15554
  walk(full);
14788
15555
  } else if (this.sync.classNameForFile(entry.name) && !this.sync.isIgnored(rel)) {
14789
- out.set(rel, fs3.readFileSync(full, "utf8"));
15556
+ out.set(rel, fs4.readFileSync(full, "utf8"));
14790
15557
  }
14791
15558
  }
14792
15559
  };
@@ -14794,12 +15561,12 @@ var init_sync_tools = __esm({
14794
15561
  return out;
14795
15562
  }
14796
15563
  _resolveSyncDir(syncDir) {
14797
- return path3.resolve(syncDir ?? process.env.ROBLOX_SYNC_DIR ?? path3.join(process.cwd(), "roblox-src"));
15564
+ return path4.resolve(syncDir ?? process.env.ROBLOX_SYNC_DIR ?? path4.join(process.cwd(), "roblox-src"));
14798
15565
  }
14799
15566
  async syncPull(syncDir, instance_id) {
14800
15567
  const dir = this._resolveSyncDir(syncDir);
14801
15568
  const scripts = await this._dumpStudioScripts(instance_id);
14802
- fs3.mkdirSync(dir, { recursive: true });
15569
+ fs4.mkdirSync(dir, { recursive: true });
14803
15570
  const manifest = {};
14804
15571
  let written = 0;
14805
15572
  let skipped = 0;
@@ -14809,9 +15576,9 @@ var init_sync_tools = __esm({
14809
15576
  skipped++;
14810
15577
  continue;
14811
15578
  }
14812
- const full = path3.join(dir, rel);
14813
- fs3.mkdirSync(path3.dirname(full), { recursive: true });
14814
- fs3.writeFileSync(full, script.source);
15579
+ const full = path4.join(dir, rel);
15580
+ fs4.mkdirSync(path4.dirname(full), { recursive: true });
15581
+ fs4.writeFileSync(full, script.source);
14815
15582
  manifest[rel] = script.source;
14816
15583
  written++;
14817
15584
  }
@@ -15136,7 +15903,7 @@ var init_opencloud_client = __esm({
15136
15903
  if (result.error) {
15137
15904
  throw new Error(`Asset upload failed: ${result.error.message}`);
15138
15905
  }
15139
- await new Promise((resolve6) => setTimeout(resolve6, intervalMs));
15906
+ await new Promise((resolve7) => setTimeout(resolve7, intervalMs));
15140
15907
  }
15141
15908
  throw new Error(`Asset upload timed out after ${maxAttempts * intervalMs / 1e3}s. Operation ID: ${operationId}`);
15142
15909
  }
@@ -15145,9 +15912,9 @@ var init_opencloud_client = __esm({
15145
15912
  });
15146
15913
 
15147
15914
  // ../core/dist/managed-instance-registry.js
15148
- import * as fs4 from "fs";
15149
- import * as os2 from "os";
15150
- import * as path4 from "path";
15915
+ import * as fs5 from "fs";
15916
+ import * as os3 from "os";
15917
+ import * as path5 from "path";
15151
15918
  function defaultManagedInstanceRegistryDir() {
15152
15919
  if (process.env.BLOXFORGE_MANAGED_INSTANCE_REGISTRY_DIR) {
15153
15920
  return process.env.BLOXFORGE_MANAGED_INSTANCE_REGISTRY_DIR;
@@ -15155,22 +15922,22 @@ function defaultManagedInstanceRegistryDir() {
15155
15922
  if (process.env.ROBLOXSTUDIO_MCP_MANAGED_INSTANCE_REGISTRY_DIR) {
15156
15923
  return process.env.ROBLOXSTUDIO_MCP_MANAGED_INSTANCE_REGISTRY_DIR;
15157
15924
  }
15158
- const suffix = path4.join("managed-instances", "v1");
15925
+ const suffix = path5.join("managed-instances", "v1");
15159
15926
  let newDir;
15160
15927
  let legacyDir;
15161
15928
  if (process.platform === "win32" && process.env.LOCALAPPDATA) {
15162
- newDir = path4.join(process.env.LOCALAPPDATA, "bloxforge", suffix);
15163
- legacyDir = path4.join(process.env.LOCALAPPDATA, "robloxstudio-mcp", suffix);
15929
+ newDir = path5.join(process.env.LOCALAPPDATA, "bloxforge", suffix);
15930
+ legacyDir = path5.join(process.env.LOCALAPPDATA, "robloxstudio-mcp", suffix);
15164
15931
  } else if (process.platform === "darwin") {
15165
- newDir = path4.join(os2.homedir(), "Library", "Application Support", "bloxforge", suffix);
15166
- legacyDir = path4.join(os2.homedir(), "Library", "Application Support", "robloxstudio-mcp", suffix);
15932
+ newDir = path5.join(os3.homedir(), "Library", "Application Support", "bloxforge", suffix);
15933
+ legacyDir = path5.join(os3.homedir(), "Library", "Application Support", "robloxstudio-mcp", suffix);
15167
15934
  } else {
15168
- newDir = path4.join(os2.homedir(), ".local", "state", "bloxforge", suffix);
15169
- legacyDir = path4.join(os2.homedir(), ".local", "state", "robloxstudio-mcp", suffix);
15935
+ newDir = path5.join(os3.homedir(), ".local", "state", "bloxforge", suffix);
15936
+ legacyDir = path5.join(os3.homedir(), ".local", "state", "robloxstudio-mcp", suffix);
15170
15937
  }
15171
- if (fs4.existsSync(newDir))
15938
+ if (fs5.existsSync(newDir))
15172
15939
  return newDir;
15173
- if (fs4.existsSync(legacyDir))
15940
+ if (fs5.existsSync(legacyDir))
15174
15941
  return legacyDir;
15175
15942
  return newDir;
15176
15943
  }
@@ -15252,20 +16019,20 @@ var init_managed_instance_registry = __esm({
15252
16019
  }
15253
16020
  withLock(fn) {
15254
16021
  this.ensureDir();
15255
- const lockDir = path4.join(this.dir, ".lock");
16022
+ const lockDir = path5.join(this.dir, ".lock");
15256
16023
  const deadline = Date.now() + LOCK_TIMEOUT_MS;
15257
16024
  while (true) {
15258
16025
  try {
15259
- fs4.mkdirSync(lockDir);
16026
+ fs5.mkdirSync(lockDir);
15260
16027
  break;
15261
16028
  } catch (error) {
15262
16029
  const code = error.code;
15263
16030
  if (code !== "EEXIST")
15264
16031
  throw error;
15265
16032
  try {
15266
- const stat = fs4.statSync(lockDir);
16033
+ const stat = fs5.statSync(lockDir);
15267
16034
  if (Date.now() - stat.mtimeMs > LOCK_STALE_MS) {
15268
- fs4.rmSync(lockDir, { recursive: true, force: true });
16035
+ fs5.rmSync(lockDir, { recursive: true, force: true });
15269
16036
  continue;
15270
16037
  }
15271
16038
  } catch {
@@ -15280,21 +16047,21 @@ var init_managed_instance_registry = __esm({
15280
16047
  try {
15281
16048
  return fn();
15282
16049
  } finally {
15283
- fs4.rmSync(lockDir, { recursive: true, force: true });
16050
+ fs5.rmSync(lockDir, { recursive: true, force: true });
15284
16051
  }
15285
16052
  }
15286
16053
  ensureDir() {
15287
- fs4.mkdirSync(this.dir, { recursive: true });
16054
+ fs5.mkdirSync(this.dir, { recursive: true });
15288
16055
  }
15289
16056
  recordPath(recordId) {
15290
- return path4.join(this.dir, `${recordId}.json`);
16057
+ return path5.join(this.dir, `${recordId}.json`);
15291
16058
  }
15292
16059
  recordFilesUnlocked() {
15293
- return fs4.readdirSync(this.dir).filter((name) => name.endsWith(".json")).map((name) => path4.join(this.dir, name));
16060
+ return fs5.readdirSync(this.dir).filter((name) => name.endsWith(".json")).map((name) => path5.join(this.dir, name));
15294
16061
  }
15295
16062
  readRecordUnlocked(recordId) {
15296
16063
  try {
15297
- const parsed = JSON.parse(fs4.readFileSync(this.recordPath(recordId), "utf8"));
16064
+ const parsed = JSON.parse(fs5.readFileSync(this.recordPath(recordId), "utf8"));
15298
16065
  return isRecord(parsed) ? parsed : void 0;
15299
16066
  } catch {
15300
16067
  return void 0;
@@ -15307,7 +16074,7 @@ var init_managed_instance_registry = __esm({
15307
16074
  const records = [];
15308
16075
  for (const file of this.recordFilesUnlocked()) {
15309
16076
  try {
15310
- const parsed = JSON.parse(fs4.readFileSync(file, "utf8"));
16077
+ const parsed = JSON.parse(fs5.readFileSync(file, "utf8"));
15311
16078
  if (!isRecord(parsed))
15312
16079
  continue;
15313
16080
  records.push(parsed);
@@ -15319,23 +16086,23 @@ var init_managed_instance_registry = __esm({
15319
16086
  writeRecordUnlocked(record) {
15320
16087
  this.ensureDir();
15321
16088
  const finalPath = this.recordPath(record.recordId);
15322
- const tmpPath = path4.join(this.dir, `${record.recordId}.${process.pid}.${Date.now()}.tmp`);
15323
- const fd = fs4.openSync(tmpPath, "w");
16089
+ const tmpPath = path5.join(this.dir, `${record.recordId}.${process.pid}.${Date.now()}.tmp`);
16090
+ const fd = fs5.openSync(tmpPath, "w");
15324
16091
  try {
15325
- fs4.writeFileSync(fd, `${JSON.stringify(record, null, 2)}
16092
+ fs5.writeFileSync(fd, `${JSON.stringify(record, null, 2)}
15326
16093
  `, "utf8");
15327
- fs4.fsyncSync(fd);
16094
+ fs5.fsyncSync(fd);
15328
16095
  } finally {
15329
- fs4.closeSync(fd);
16096
+ fs5.closeSync(fd);
15330
16097
  }
15331
- fs4.renameSync(tmpPath, finalPath);
16098
+ fs5.renameSync(tmpPath, finalPath);
15332
16099
  }
15333
16100
  deleteRecordUnlocked(recordId) {
15334
- fs4.rmSync(this.recordPath(recordId), { force: true });
16101
+ fs5.rmSync(this.recordPath(recordId), { force: true });
15335
16102
  }
15336
16103
  appendEventUnlocked(event, now) {
15337
- const file = path4.join(this.dir, `events-${ymd(now)}.jsonl`);
15338
- fs4.appendFileSync(file, `${JSON.stringify({
16104
+ const file = path5.join(this.dir, `events-${ymd(now)}.jsonl`);
16105
+ fs5.appendFileSync(file, `${JSON.stringify({
15339
16106
  ts: new Date(now).toISOString(),
15340
16107
  ...event
15341
16108
  })}
@@ -15343,12 +16110,12 @@ var init_managed_instance_registry = __esm({
15343
16110
  }
15344
16111
  cleanupOldEventLogsUnlocked(now) {
15345
16112
  const cutoff = ymd(now - EVENT_RETENTION_DAYS * 24 * 60 * 60 * 1e3);
15346
- for (const name of fs4.readdirSync(this.dir)) {
16113
+ for (const name of fs5.readdirSync(this.dir)) {
15347
16114
  const date = eventLogDate(name);
15348
16115
  if (!date || date >= cutoff)
15349
16116
  continue;
15350
16117
  try {
15351
- fs4.rmSync(path4.join(this.dir, name), { force: true });
16118
+ fs5.rmSync(path5.join(this.dir, name), { force: true });
15352
16119
  } catch {
15353
16120
  }
15354
16121
  }
@@ -15372,9 +16139,9 @@ var init_managed_instance_registry = __esm({
15372
16139
  for (const file of this.recordFilesUnlocked()) {
15373
16140
  let parsed;
15374
16141
  try {
15375
- parsed = JSON.parse(fs4.readFileSync(file, "utf8"));
16142
+ parsed = JSON.parse(fs5.readFileSync(file, "utf8"));
15376
16143
  } catch {
15377
- fs4.rmSync(file, { force: true });
16144
+ fs5.rmSync(file, { force: true });
15378
16145
  this.appendEventUnlocked({
15379
16146
  event: "registry_pruned_malformed_record",
15380
16147
  reason: "parse_error",
@@ -15383,7 +16150,7 @@ var init_managed_instance_registry = __esm({
15383
16150
  continue;
15384
16151
  }
15385
16152
  if (!parsed || typeof parsed !== "object") {
15386
- fs4.rmSync(file, { force: true });
16153
+ fs5.rmSync(file, { force: true });
15387
16154
  this.appendEventUnlocked({
15388
16155
  event: "registry_pruned_malformed_record",
15389
16156
  reason: "invalid_shape",
@@ -15395,7 +16162,7 @@ var init_managed_instance_registry = __esm({
15395
16162
  if (typeof version === "number" && version > REGISTRY_VERSION)
15396
16163
  continue;
15397
16164
  if (!isRecord(parsed)) {
15398
- fs4.rmSync(file, { force: true });
16165
+ fs5.rmSync(file, { force: true });
15399
16166
  this.appendEventUnlocked({
15400
16167
  event: "registry_pruned_malformed_record",
15401
16168
  reason: "invalid_shape",
@@ -15404,7 +16171,7 @@ var init_managed_instance_registry = __esm({
15404
16171
  continue;
15405
16172
  }
15406
16173
  if (parsed.closedAt !== void 0) {
15407
- fs4.rmSync(file, { force: true });
16174
+ fs5.rmSync(file, { force: true });
15408
16175
  this.appendEventUnlocked({
15409
16176
  event: "registry_pruned_closed_record",
15410
16177
  recordId: parsed.recordId,
@@ -15417,7 +16184,7 @@ var init_managed_instance_registry = __esm({
15417
16184
  }
15418
16185
  if (parsed.bootId !== options.currentBootId) {
15419
16186
  this.cleanupRecord(options, parsed);
15420
- fs4.rmSync(file, { force: true });
16187
+ fs5.rmSync(file, { force: true });
15421
16188
  this.appendEventUnlocked({
15422
16189
  event: "registry_pruned_previous_boot",
15423
16190
  recordId: parsed.recordId,
@@ -15430,7 +16197,7 @@ var init_managed_instance_registry = __esm({
15430
16197
  }
15431
16198
  if (options.isProcessRunning && (parsed.nativeProcessId || parsed.spawnPid) && !options.isProcessRunning(parsed)) {
15432
16199
  this.cleanupRecord(options, parsed);
15433
- fs4.rmSync(file, { force: true });
16200
+ fs5.rmSync(file, { force: true });
15434
16201
  this.appendEventUnlocked({
15435
16202
  event: "registry_pruned_stale_process",
15436
16203
  recordId: parsed.recordId,
@@ -15448,10 +16215,10 @@ var init_managed_instance_registry = __esm({
15448
16215
 
15449
16216
  // ../core/dist/studio-instance-manager.js
15450
16217
  import { execFileSync, spawn } from "child_process";
15451
- import { copyFileSync, existsSync as existsSync3, mkdirSync as mkdirSync5, readdirSync as readdirSync4, readFileSync as readFileSync4, realpathSync, rmSync as rmSync2, statSync as statSync3 } from "fs";
16218
+ import { copyFileSync as copyFileSync2, existsSync as existsSync4, mkdirSync as mkdirSync6, readdirSync as readdirSync4, readFileSync as readFileSync5, realpathSync, rmSync as rmSync2, statSync as statSync3 } from "fs";
15452
16219
  import { randomUUID as randomUUID2 } from "crypto";
15453
- import * as os3 from "os";
15454
- import * as path5 from "path";
16220
+ import * as os4 from "os";
16221
+ import * as path6 from "path";
15455
16222
  function run(command, args, options = {}) {
15456
16223
  return execFileSync(command, args, {
15457
16224
  encoding: "utf8",
@@ -15463,14 +16230,14 @@ function isWsl() {
15463
16230
  if (process.platform !== "linux")
15464
16231
  return false;
15465
16232
  try {
15466
- return /microsoft|wsl/i.test(readFileSync4("/proc/version", "utf8"));
16233
+ return /microsoft|wsl/i.test(readFileSync5("/proc/version", "utf8"));
15467
16234
  } catch {
15468
16235
  return false;
15469
16236
  }
15470
16237
  }
15471
16238
  function powershell(script) {
15472
16239
  return run("powershell.exe", ["-NoProfile", "-Command", script], {
15473
- cwd: isWsl() && existsSync3("/mnt/c/Windows") ? "/mnt/c/Windows" : process.cwd()
16240
+ cwd: isWsl() && existsSync4("/mnt/c/Windows") ? "/mnt/c/Windows" : process.cwd()
15474
16241
  });
15475
16242
  }
15476
16243
  function windowsLocalAppData() {
@@ -15480,7 +16247,7 @@ function windowsLocalAppData() {
15480
16247
  return void 0;
15481
16248
  try {
15482
16249
  return run("cmd.exe", ["/c", "echo %LOCALAPPDATA%"], {
15483
- cwd: existsSync3("/mnt/c/Windows") ? "/mnt/c/Windows" : process.cwd()
16250
+ cwd: existsSync4("/mnt/c/Windows") ? "/mnt/c/Windows" : process.cwd()
15484
16251
  });
15485
16252
  } catch {
15486
16253
  return void 0;
@@ -15492,7 +16259,7 @@ function toWslPath(windowsPath) {
15492
16259
  return run("wslpath", ["-u", windowsPath]);
15493
16260
  }
15494
16261
  function toStudioLaunchArg(arg) {
15495
- if (!isWsl() || !path5.isAbsolute(arg) || !existsSync3(arg))
16262
+ if (!isWsl() || !path6.isAbsolute(arg) || !existsSync4(arg))
15496
16263
  return arg;
15497
16264
  return run("wslpath", ["-w", arg]);
15498
16265
  }
@@ -15501,24 +16268,24 @@ function resolveEntrypointDir() {
15501
16268
  if (!entrypoint)
15502
16269
  return void 0;
15503
16270
  try {
15504
- return path5.dirname(realpathSync(entrypoint));
16271
+ return path6.dirname(realpathSync(entrypoint));
15505
16272
  } catch {
15506
- return path5.dirname(path5.resolve(entrypoint));
16273
+ return path6.dirname(path6.resolve(entrypoint));
15507
16274
  }
15508
16275
  }
15509
16276
  function resolveBaseplateTemplatePath() {
15510
16277
  const entrypointDir = resolveEntrypointDir();
15511
16278
  const candidates = [
15512
16279
  ...entrypointDir ? [
15513
- path5.join(entrypointDir, "assets", BASEPLATE_TEMPLATE_NAME),
15514
- path5.join(entrypointDir, "..", "assets", BASEPLATE_TEMPLATE_NAME)
16280
+ path6.join(entrypointDir, "assets", BASEPLATE_TEMPLATE_NAME),
16281
+ path6.join(entrypointDir, "..", "assets", BASEPLATE_TEMPLATE_NAME)
15515
16282
  ] : [],
15516
- path5.join(process.cwd(), "packages", "core", "assets", BASEPLATE_TEMPLATE_NAME),
15517
- path5.join(process.cwd(), "packages", "robloxstudio-mcp", "dist", "assets", BASEPLATE_TEMPLATE_NAME),
15518
- path5.join(process.cwd(), "assets", BASEPLATE_TEMPLATE_NAME)
16283
+ path6.join(process.cwd(), "packages", "core", "assets", BASEPLATE_TEMPLATE_NAME),
16284
+ path6.join(process.cwd(), "packages", "robloxstudio-mcp", "dist", "assets", BASEPLATE_TEMPLATE_NAME),
16285
+ path6.join(process.cwd(), "assets", BASEPLATE_TEMPLATE_NAME)
15519
16286
  ];
15520
16287
  for (const candidate of candidates) {
15521
- if (existsSync3(candidate))
16288
+ if (existsSync4(candidate))
15522
16289
  return candidate;
15523
16290
  }
15524
16291
  throw new Error(`Baseplate template not found. Expected ${BASEPLATE_TEMPLATE_NAME} in one of: ${candidates.join(", ")}`);
@@ -15545,7 +16312,7 @@ function sweepStaleBaseplateFiles() {
15545
16312
  continue;
15546
16313
  if (Number(match[1]) !== process.pid && isProcessAlive(Number(match[1])))
15547
16314
  continue;
15548
- const file = path5.join(BASEPLATE_TEMP_DIR, entry);
16315
+ const file = path6.join(BASEPLATE_TEMP_DIR, entry);
15549
16316
  try {
15550
16317
  if (statSync3(file).mtimeMs < cutoff)
15551
16318
  rmSync2(file, { force: true });
@@ -15554,15 +16321,15 @@ function sweepStaleBaseplateFiles() {
15554
16321
  }
15555
16322
  }
15556
16323
  function createBaseplatePlaceFile() {
15557
- mkdirSync5(BASEPLATE_TEMP_DIR, { recursive: true });
16324
+ mkdirSync6(BASEPLATE_TEMP_DIR, { recursive: true });
15558
16325
  sweepStaleBaseplateFiles();
15559
- const file = path5.join(BASEPLATE_TEMP_DIR, `Baseplate-${process.pid}-${Date.now()}.rbxl`);
15560
- copyFileSync(resolveBaseplateTemplatePath(), file);
16326
+ const file = path6.join(BASEPLATE_TEMP_DIR, `Baseplate-${process.pid}-${Date.now()}.rbxl`);
16327
+ copyFileSync2(resolveBaseplateTemplatePath(), file);
15561
16328
  return file;
15562
16329
  }
15563
16330
  function isGeneratedBaseplatePlaceFile(file) {
15564
- const resolvedFile = path5.resolve(file);
15565
- return path5.dirname(resolvedFile) === path5.resolve(BASEPLATE_TEMP_DIR) && BASEPLATE_TEMP_NAME.test(path5.basename(resolvedFile));
16331
+ const resolvedFile = path6.resolve(file);
16332
+ return path6.dirname(resolvedFile) === path6.resolve(BASEPLATE_TEMP_DIR) && BASEPLATE_TEMP_NAME.test(path6.basename(resolvedFile));
15566
16333
  }
15567
16334
  function cleanupManagedBaseplateFiles(record) {
15568
16335
  if (record.source !== "baseplate" || !record.localPlaceFile)
@@ -15594,11 +16361,11 @@ function resolveStudioExe() {
15594
16361
  throw new Error("Roblox Studio executable auto-discovery is only supported on Windows, WSL, and macOS. Set ROBLOX_STUDIO_EXE.");
15595
16362
  }
15596
16363
  const localAppData = windowsLocalAppData();
15597
- const root = localAppData ? path5.join(toWslPath(localAppData), "Roblox", "Versions") : path5.join(os3.homedir(), "AppData", "Local", "Roblox", "Versions");
15598
- if (!existsSync3(root)) {
16364
+ const root = localAppData ? path6.join(toWslPath(localAppData), "Roblox", "Versions") : path6.join(os4.homedir(), "AppData", "Local", "Roblox", "Versions");
16365
+ if (!existsSync4(root)) {
15599
16366
  throw new Error(`Roblox Studio Versions folder not found: ${root}. Set ROBLOX_STUDIO_EXE.`);
15600
16367
  }
15601
- const candidates = readdirSync4(root).filter((name) => name.startsWith("version-")).map((name) => path5.join(root, name, "RobloxStudioBeta.exe")).filter((candidate) => existsSync3(candidate)).sort((a, b) => statSync3(b).mtimeMs - statSync3(a).mtimeMs);
16368
+ const candidates = readdirSync4(root).filter((name) => name.startsWith("version-")).map((name) => path6.join(root, name, "RobloxStudioBeta.exe")).filter((candidate) => existsSync4(candidate)).sort((a, b) => statSync3(b).mtimeMs - statSync3(a).mtimeMs);
15602
16369
  if (candidates.length === 0) {
15603
16370
  throw new Error(`RobloxStudioBeta.exe not found under ${root}. Set ROBLOX_STUDIO_EXE.`);
15604
16371
  }
@@ -15633,7 +16400,7 @@ function listStudioProcesses() {
15633
16400
  function currentBootId() {
15634
16401
  if (process.platform === "linux") {
15635
16402
  try {
15636
- return readFileSync4("/proc/sys/kernel/random/boot_id", "utf8").trim();
16403
+ return readFileSync5("/proc/sys/kernel/random/boot_id", "utf8").trim();
15637
16404
  } catch {
15638
16405
  }
15639
16406
  }
@@ -15649,7 +16416,7 @@ function currentBootId() {
15649
16416
  } catch {
15650
16417
  }
15651
16418
  }
15652
- return `${process.platform}:${os3.hostname()}:unknown-boot`;
16419
+ return `${process.platform}:${os4.hostname()}:unknown-boot`;
15653
16420
  }
15654
16421
  function buildStudioLaunchArgs(options) {
15655
16422
  switch (options.source) {
@@ -15685,17 +16452,17 @@ function buildStudioLaunchArgs(options) {
15685
16452
  }
15686
16453
  }
15687
16454
  function delay(ms) {
15688
- return new Promise((resolve6) => setTimeout(resolve6, ms));
16455
+ return new Promise((resolve7) => setTimeout(resolve7, ms));
15689
16456
  }
15690
16457
  function basenameAny(filePath) {
15691
- return path5.basename(filePath.replace(/\\/g, "/"));
16458
+ return path6.basename(filePath.replace(/\\/g, "/"));
15692
16459
  }
15693
16460
  var BASEPLATE_TEMP_DIR, BASEPLATE_TEMP_NAME, BASEPLATE_TEMPLATE_NAME, STALE_BASEPLATE_MAX_AGE_MS, BASEPLATE_TEMP_SWEEP_NAME, StudioInstanceManager;
15694
16461
  var init_studio_instance_manager = __esm({
15695
16462
  "../core/dist/studio-instance-manager.js"() {
15696
16463
  "use strict";
15697
16464
  init_managed_instance_registry();
15698
- BASEPLATE_TEMP_DIR = path5.join(os3.tmpdir(), "robloxstudio-mcp-baseplates");
16465
+ BASEPLATE_TEMP_DIR = path6.join(os4.tmpdir(), "robloxstudio-mcp-baseplates");
15699
16466
  BASEPLATE_TEMP_NAME = /^Baseplate-\d+-\d+\.rbxl$/;
15700
16467
  BASEPLATE_TEMPLATE_NAME = "Baseplate.rbxl";
15701
16468
  STALE_BASEPLATE_MAX_AGE_MS = 24 * 60 * 60 * 1e3;
@@ -15744,7 +16511,7 @@ var init_studio_instance_manager = __esm({
15744
16511
  const exe = this.processAdapter.resolveStudioExe?.() ?? resolveStudioExe();
15745
16512
  const args = buildStudioLaunchArgs(preparedOptions).map(toStudioLaunchArg);
15746
16513
  const spawnOptions = {
15747
- cwd: isWsl() && existsSync3("/mnt/c/Windows") ? "/mnt/c/Windows" : process.cwd(),
16514
+ cwd: isWsl() && existsSync4("/mnt/c/Windows") ? "/mnt/c/Windows" : process.cwd(),
15748
16515
  detached: true,
15749
16516
  stdio: "ignore"
15750
16517
  };
@@ -15950,13 +16717,13 @@ var init_studio_instance_manager = __esm({
15950
16717
  const processId = record.nativeProcessId ?? record.spawnPid;
15951
16718
  if (record.spawnPid && record.spawnPid === processId && studioProcess.Id === processId)
15952
16719
  return true;
15953
- const processPath = studioProcess.Path ? path5.normalize(studioProcess.Path).toLowerCase() : "";
15954
- const exePath = record.exe ? path5.normalize(record.exe).toLowerCase() : "";
16720
+ const processPath = studioProcess.Path ? path6.normalize(studioProcess.Path).toLowerCase() : "";
16721
+ const exePath = record.exe ? path6.normalize(record.exe).toLowerCase() : "";
15955
16722
  if (processPath && exePath && (processPath === exePath || basenameAny(processPath) === basenameAny(exePath))) {
15956
16723
  return true;
15957
16724
  }
15958
16725
  const commandLine = studioProcess.CommandLine ?? "";
15959
- if (record.localPlaceFile && commandLine.includes(path5.basename(record.localPlaceFile)))
16726
+ if (record.localPlaceFile && commandLine.includes(path6.basename(record.localPlaceFile)))
15960
16727
  return true;
15961
16728
  if (record.placeId !== void 0 && commandLine.includes(String(record.placeId)))
15962
16729
  return true;
@@ -16157,10 +16924,207 @@ var init_session_recorder = __esm({
16157
16924
  }
16158
16925
  });
16159
16926
 
16927
+ // ../core/dist/quality-tools.js
16928
+ import { execFileSync as execFileSync2 } from "child_process";
16929
+ import * as fs6 from "fs";
16930
+ import * as os5 from "os";
16931
+ import * as path7 from "path";
16932
+ function within(root, candidate) {
16933
+ const relative3 = path7.relative(root, candidate);
16934
+ return relative3 === "" || !relative3.startsWith("..") && !path7.isAbsolute(relative3);
16935
+ }
16936
+ function allowedProjectRoot(root) {
16937
+ const candidate = path7.resolve(root);
16938
+ if (process.env.NODE_ENV === "test")
16939
+ return candidate;
16940
+ const allowed = path7.resolve(process.env.BLOXFORGE_PROJECT_ROOT?.trim() || process.cwd());
16941
+ if (!within(allowed, candidate))
16942
+ throw new Error(`Project root must stay within ${allowed}`);
16943
+ return candidate;
16944
+ }
16945
+ function hasCommand(command) {
16946
+ try {
16947
+ execFileSync2(command, ["--version"], { stdio: "pipe", timeout: 3e3 });
16948
+ return true;
16949
+ } catch (error) {
16950
+ return error?.code !== "ENOENT";
16951
+ }
16952
+ }
16953
+ function projectFiles(root) {
16954
+ const names = [
16955
+ "default.project.json",
16956
+ "rojo.json",
16957
+ "rojo.project.json",
16958
+ "sourcemap.json",
16959
+ "selene.toml",
16960
+ "stylua.toml",
16961
+ "wally.toml",
16962
+ "wally.lock",
16963
+ "rokit.toml",
16964
+ "aftman.toml"
16965
+ ];
16966
+ return Object.fromEntries(names.map((name) => [name, fs6.existsSync(path7.join(root, name)) ? path7.join(root, name) : void 0]).filter((entry) => entry[1] !== void 0));
16967
+ }
16968
+ function run2(command, args, options = {}) {
16969
+ if (!hasCommand(command))
16970
+ return { tool: command, available: false, ok: false, error: `${command} is not installed` };
16971
+ try {
16972
+ const output = execFileSync2(command, args, {
16973
+ cwd: options.cwd,
16974
+ input: options.input,
16975
+ encoding: "utf8",
16976
+ stdio: ["pipe", "pipe", "pipe"],
16977
+ timeout: 12e4,
16978
+ maxBuffer: 10 * 1024 * 1024
16979
+ });
16980
+ return { tool: command, available: true, ok: true, output: output.trim() };
16981
+ } catch (error) {
16982
+ return {
16983
+ tool: command,
16984
+ available: true,
16985
+ ok: false,
16986
+ output: [error?.stdout, error?.stderr].filter(Boolean).join("\n").trim(),
16987
+ error: error?.message ?? String(error),
16988
+ exitCode: typeof error?.status === "number" ? error.status : void 0
16989
+ };
16990
+ }
16991
+ }
16992
+ var TOOL_COMMANDS, QualityTools;
16993
+ var init_quality_tools = __esm({
16994
+ "../core/dist/quality-tools.js"() {
16995
+ "use strict";
16996
+ TOOL_COMMANDS = ["luau-analyze", "luau-lsp", "stylua", "selene", "rojo", "rokit", "wally", "lune"];
16997
+ QualityTools = class {
16998
+ detectRobloxProject(root = process.cwd()) {
16999
+ const boundary = process.env.NODE_ENV === "test" ? path7.parse(path7.resolve(root)).root : path7.resolve(process.env.BLOXFORGE_PROJECT_ROOT?.trim() || process.cwd());
17000
+ let current = allowedProjectRoot(root);
17001
+ while (true) {
17002
+ const files = projectFiles(current);
17003
+ if (Object.keys(files).length > 0) {
17004
+ return {
17005
+ root: current,
17006
+ files,
17007
+ availableTools: TOOL_COMMANDS.filter(hasCommand)
17008
+ };
17009
+ }
17010
+ const parent = path7.dirname(current);
17011
+ if (parent === current || !within(boundary, parent))
17012
+ break;
17013
+ current = parent;
17014
+ }
17015
+ return { root: path7.resolve(root), files: {}, availableTools: TOOL_COMMANDS.filter(hasCommand) };
17016
+ }
17017
+ validateScriptSource(source, fileName = "script.server.lua") {
17018
+ if (typeof source !== "string")
17019
+ throw new Error("source is required");
17020
+ const dir = fs6.mkdtempSync(path7.join(os5.tmpdir(), "bloxforge-quality-"));
17021
+ const file = path7.join(dir, path7.basename(fileName));
17022
+ fs6.writeFileSync(file, source, "utf8");
17023
+ const checks = [
17024
+ run2("luau-analyze", [file]),
17025
+ run2("selene", [file]),
17026
+ run2("stylua", ["--check", file])
17027
+ ];
17028
+ fs6.rmSync(dir, { recursive: true, force: true });
17029
+ return { checks };
17030
+ }
17031
+ formatScriptPreview(source, fileName = "script.server.lua") {
17032
+ if (typeof source !== "string")
17033
+ throw new Error("source is required");
17034
+ return run2("stylua", ["--stdin-filepath", fileName], { input: source });
17035
+ }
17036
+ resolveInstanceSourceFile(instancePath, root = process.cwd()) {
17037
+ const project = this.detectRobloxProject(root);
17038
+ const sourcemapPath = project.files["sourcemap.json"];
17039
+ if (!sourcemapPath)
17040
+ return { resolved: false, reason: "sourcemap.json not found", instancePath };
17041
+ let sourcemap;
17042
+ try {
17043
+ sourcemap = JSON.parse(fs6.readFileSync(sourcemapPath, "utf8"));
17044
+ } catch (error) {
17045
+ return { resolved: false, reason: `invalid sourcemap: ${error instanceof Error ? error.message : String(error)}`, instancePath };
17046
+ }
17047
+ const target = instancePath.split(".").filter(Boolean).reduce((node, segment) => {
17048
+ if (!node || typeof node !== "object")
17049
+ return void 0;
17050
+ return node.children?.find((child) => child.name === segment);
17051
+ }, sourcemap);
17052
+ return target ? { resolved: true, instancePath, node: target } : { resolved: false, instancePath, reason: "instance not found" };
17053
+ }
17054
+ getDependencyGraph(root = process.cwd()) {
17055
+ const project = this.detectRobloxProject(root);
17056
+ const lock = project.files["wally.lock"];
17057
+ const dependencies = [];
17058
+ if (lock) {
17059
+ for (const line of fs6.readFileSync(lock, "utf8").split(/\r?\n/)) {
17060
+ const match = line.match(/^\s*([\w.-]+)\s*=/);
17061
+ if (match && match[1] !== "version")
17062
+ dependencies.push(match[1]);
17063
+ }
17064
+ }
17065
+ return { root: project.root, manifest: project.files["wally.toml"], lockfile: lock, dependencies: [...new Set(dependencies)] };
17066
+ }
17067
+ installWallyPackages(root = process.cwd(), confirm = false) {
17068
+ if (!confirm)
17069
+ return { tool: "wally", available: hasCommand("wally"), ok: false, error: "Confirmation required: pass confirm=true to install packages" };
17070
+ return run2("wally", ["install"], { cwd: this.detectRobloxProject(root).root });
17071
+ }
17072
+ runProjectTests(root = process.cwd(), script) {
17073
+ if (!script)
17074
+ return { tool: "lune", available: hasCommand("lune"), ok: false, error: "script is required" };
17075
+ const project = this.detectRobloxProject(root);
17076
+ const scriptPath = path7.resolve(project.root, script);
17077
+ if (!within(project.root, scriptPath))
17078
+ return { tool: "lune", available: hasCommand("lune"), ok: false, error: "script must stay within project root" };
17079
+ return run2("lune", ["run", scriptPath], { cwd: project.root });
17080
+ }
17081
+ validateWithLuauLsp(root = process.cwd(), files = ["."]) {
17082
+ const project = this.detectRobloxProject(root);
17083
+ const args = ["analyze", ...files];
17084
+ if (project.files["sourcemap.json"])
17085
+ args.push("--sourcemap", project.files["sourcemap.json"]);
17086
+ return run2("luau-lsp", args, { cwd: project.root });
17087
+ }
17088
+ generateRojoSourcemap(root = process.cwd(), output = "sourcemap.json") {
17089
+ const project = this.detectRobloxProject(root);
17090
+ const projectFile = project.files["default.project.json"] ?? project.files["rojo.project.json"];
17091
+ if (!projectFile)
17092
+ return { tool: "rojo", available: hasCommand("rojo"), ok: false, error: "Rojo project file not found" };
17093
+ const outputPath = path7.resolve(project.root, output);
17094
+ if (!within(project.root, outputPath))
17095
+ return { tool: "rojo", available: hasCommand("rojo"), ok: false, error: "output must stay within project root" };
17096
+ return run2("rojo", ["sourcemap", projectFile, "--output", outputPath], { cwd: project.root });
17097
+ }
17098
+ buildRojoProject(root = process.cwd(), output) {
17099
+ const project = this.detectRobloxProject(root);
17100
+ const projectFile = project.files["default.project.json"] ?? project.files["rojo.project.json"];
17101
+ if (!projectFile)
17102
+ return { tool: "rojo", available: hasCommand("rojo"), ok: false, error: "Rojo project file not found" };
17103
+ if (!output)
17104
+ return { tool: "rojo", available: hasCommand("rojo"), ok: false, error: "output is required" };
17105
+ const outputPath = path7.resolve(project.root, output);
17106
+ if (!within(project.root, outputPath))
17107
+ return { tool: "rojo", available: hasCommand("rojo"), ok: false, error: "output must stay within project root" };
17108
+ return run2("rojo", ["build", projectFile, "--output", outputPath], { cwd: project.root });
17109
+ }
17110
+ runQualityGate(root = process.cwd()) {
17111
+ const project = this.detectRobloxProject(root);
17112
+ const checks = [
17113
+ run2("rojo", ["sourcemap"], { cwd: project.root }),
17114
+ run2("selene", ["."], { cwd: project.root }),
17115
+ run2("stylua", ["--check", "."], { cwd: project.root }),
17116
+ this.validateWithLuauLsp(project.root)
17117
+ ];
17118
+ return { project, checks };
17119
+ }
17120
+ };
17121
+ }
17122
+ });
17123
+
16160
17124
  // ../core/dist/tools/index.js
16161
- import * as fs5 from "fs";
16162
- import * as os4 from "os";
16163
- import * as path6 from "path";
17125
+ import * as fs7 from "fs";
17126
+ import * as os6 from "os";
17127
+ import * as path8 from "path";
16164
17128
  import * as crypto from "crypto";
16165
17129
  function requiresAttribution(license) {
16166
17130
  return /cc[\s-]?by|attribution/i.test(license ?? "");
@@ -16220,8 +17184,8 @@ function loadMicroProfilerBaseline(source, sourcePath) {
16220
17184
  if (sourcePath !== void 0) {
16221
17185
  if (typeof sourcePath !== "string" || sourcePath === "")
16222
17186
  throw new Error("baseline_path must be a non-empty string when provided");
16223
- const resolved = path6.resolve(sourcePath);
16224
- const parsed = JSON.parse(fs5.readFileSync(resolved, "utf8"));
17187
+ const resolved = path8.resolve(sourcePath);
17188
+ const parsed = JSON.parse(fs7.readFileSync(resolved, "utf8"));
16225
17189
  const record = asRecord(parsed);
16226
17190
  if (!record)
16227
17191
  throw new Error(`baseline_path did not contain a JSON object: ${resolved}`);
@@ -16353,6 +17317,7 @@ var init_tools = __esm({
16353
17317
  init_roblox_cookie_client();
16354
17318
  init_roblox_docs();
16355
17319
  init_session_recorder();
17320
+ init_quality_tools();
16356
17321
  init_runtime_support();
16357
17322
  RobloxStudioTools = class _RobloxStudioTools {
16358
17323
  client;
@@ -16375,6 +17340,7 @@ var init_tools = __esm({
16375
17340
  runtimeTools;
16376
17341
  episodes;
16377
17342
  sessionRecorder;
17343
+ qualityTools;
16378
17344
  /** Provenance for externally-imported assets (Track A) — source/license/hash/assetId. */
16379
17345
  provenance = /* @__PURE__ */ new Map();
16380
17346
  constructor(bridge) {
@@ -16412,7 +17378,7 @@ var init_tools = __esm({
16412
17378
  this.scriptTools = new ScriptTools({
16413
17379
  callSingle: this._callSingle.bind(this),
16414
17380
  safetyGate: this._safetyGate.bind(this),
16415
- backupScript: (path7, source) => this.safety.backupScript(path7, source),
17381
+ backupScript: (path9, source) => this.safety.backupScript(path9, source),
16416
17382
  recordOperation: (kind, summary) => this.safety.recordOperation({ kind, summary })
16417
17383
  });
16418
17384
  this.mutationTools = new MutationTools({
@@ -16431,6 +17397,7 @@ var init_tools = __esm({
16431
17397
  });
16432
17398
  this.episodes = new EpisodeStore();
16433
17399
  this.sessionRecorder = new SessionRecorder();
17400
+ this.qualityTools = new QualityTools();
16434
17401
  this.runtimeTools = new RuntimeTools({
16435
17402
  bridge: this.bridge,
16436
17403
  client: this.client,
@@ -16451,6 +17418,53 @@ var init_tools = __esm({
16451
17418
  async getSessionSummary() {
16452
17419
  return wrapToolJsonText(this.sessionRecorder.summarize());
16453
17420
  }
17421
+ async getRequestStatus(requestId) {
17422
+ if (!requestId)
17423
+ throw new Error("requestId is required for get_request_status");
17424
+ const status = await this.bridge.lookupRequestStatus(requestId);
17425
+ return wrapToolJsonText(status ?? { error: "request_not_found", requestId });
17426
+ }
17427
+ async getTransportDiagnostics() {
17428
+ return wrapToolJsonText(this.bridge.getTransportDiagnostics());
17429
+ }
17430
+ async cancelRequest(requestId) {
17431
+ if (!requestId)
17432
+ throw new Error("requestId is required for cancel_request");
17433
+ return wrapToolJsonText({ requestId, cancelled: await this.bridge.requestCancellation(requestId) });
17434
+ }
17435
+ async detectRobloxProject(root) {
17436
+ return wrapToolJsonText(this.qualityTools.detectRobloxProject(root));
17437
+ }
17438
+ async validateScriptSource(source, fileName) {
17439
+ return wrapToolJsonText(this.qualityTools.validateScriptSource(source, fileName));
17440
+ }
17441
+ async formatScriptPreview(source, fileName) {
17442
+ return wrapToolJsonText(this.qualityTools.formatScriptPreview(source, fileName));
17443
+ }
17444
+ async resolveInstanceSourceFile(instancePath, root) {
17445
+ return wrapToolJsonText(this.qualityTools.resolveInstanceSourceFile(instancePath, root));
17446
+ }
17447
+ async runProjectTests(root, script) {
17448
+ return wrapToolJsonText(this.qualityTools.runProjectTests(root, script));
17449
+ }
17450
+ async getDependencyGraph(root) {
17451
+ return wrapToolJsonText(this.qualityTools.getDependencyGraph(root));
17452
+ }
17453
+ async installWallyPackages(root, confirm) {
17454
+ return wrapToolJsonText(this.qualityTools.installWallyPackages(root, confirm));
17455
+ }
17456
+ async runQualityGate(root) {
17457
+ return wrapToolJsonText(this.qualityTools.runQualityGate(root));
17458
+ }
17459
+ async validateWithLuauLsp(root, files) {
17460
+ return wrapToolJsonText(this.qualityTools.validateWithLuauLsp(root, files));
17461
+ }
17462
+ async generateRojoSourcemap(root, output) {
17463
+ return wrapToolJsonText(this.qualityTools.generateRojoSourcemap(root, output));
17464
+ }
17465
+ async buildRojoProject(root, output) {
17466
+ return wrapToolJsonText(this.qualityTools.buildRojoProject(root, output));
17467
+ }
16454
17468
  async getRobloxDocs(name, docType, section) {
16455
17469
  if (!name || typeof name !== "string") {
16456
17470
  throw new Error('get_roblox_docs requires a name (e.g. "ProximityPrompt")');
@@ -16628,8 +17642,8 @@ var init_tools = __esm({
16628
17642
  }
16629
17643
  // Scene-read inspection tools live in SceneReadTools; the facade delegates with
16630
17644
  // identical signatures.
16631
- async getFileTree(path7 = "", instance_id) {
16632
- return this.sceneReadTools.getFileTree(path7, instance_id);
17645
+ async getFileTree(path9 = "", instance_id) {
17646
+ return this.sceneReadTools.getFileTree(path9, instance_id);
16633
17647
  }
16634
17648
  async searchFiles(query, searchType = "name", instance_id) {
16635
17649
  const response = await this._callSingle("/api/search-files", { query, searchType }, void 0, instance_id);
@@ -16663,8 +17677,8 @@ var init_tools = __esm({
16663
17677
  async getClassInfo(className, instance_id) {
16664
17678
  return this.sceneReadTools.getClassInfo(className, instance_id);
16665
17679
  }
16666
- async getProjectStructure(path7, maxDepth, scriptsOnly, instance_id) {
16667
- return this.sceneReadTools.getProjectStructure(path7, maxDepth, scriptsOnly, instance_id);
17680
+ async getProjectStructure(path9, maxDepth, scriptsOnly, instance_id) {
17681
+ return this.sceneReadTools.getProjectStructure(path9, maxDepth, scriptsOnly, instance_id);
16668
17682
  }
16669
17683
  // Mutation tools live in MutationTools; the facade delegates with identical signatures.
16670
17684
  async setProperty(instancePath, propertyName, propertyValue, instance_id) {
@@ -16848,14 +17862,14 @@ var init_tools = __esm({
16848
17862
  return this.runtimeTools.redo(instance_id);
16849
17863
  }
16850
17864
  static findProjectRoot(startDir) {
16851
- let dir = path6.resolve(startDir);
17865
+ let dir = path8.resolve(startDir);
16852
17866
  let previous = "";
16853
17867
  while (dir !== previous) {
16854
- if (fs5.existsSync(path6.join(dir, ".git")) || fs5.existsSync(path6.join(dir, "package.json"))) {
17868
+ if (fs7.existsSync(path8.join(dir, ".git")) || fs7.existsSync(path8.join(dir, "package.json"))) {
16855
17869
  return dir;
16856
17870
  }
16857
17871
  previous = dir;
16858
- dir = path6.dirname(dir);
17872
+ dir = path8.dirname(dir);
16859
17873
  }
16860
17874
  return null;
16861
17875
  }
@@ -16863,15 +17877,15 @@ var init_tools = __esm({
16863
17877
  if (!candidate)
16864
17878
  return false;
16865
17879
  try {
16866
- return fs5.statSync(candidate).isDirectory();
17880
+ return fs7.statSync(candidate).isDirectory();
16867
17881
  } catch {
16868
17882
  return false;
16869
17883
  }
16870
17884
  }
16871
17885
  static ensureWritableDirectory(candidate, label) {
16872
- const resolved = path6.resolve(candidate);
17886
+ const resolved = path8.resolve(candidate);
16873
17887
  try {
16874
- fs5.mkdirSync(resolved, { recursive: true });
17888
+ fs7.mkdirSync(resolved, { recursive: true });
16875
17889
  } catch (error) {
16876
17890
  throw new Error(`Unable to create ${label} build-library directory at ${resolved}: ${error.message}`);
16877
17891
  }
@@ -16879,7 +17893,7 @@ var init_tools = __esm({
16879
17893
  throw new Error(`${label} build-library path is not a directory: ${resolved}`);
16880
17894
  }
16881
17895
  try {
16882
- fs5.accessSync(resolved, fs5.constants.W_OK);
17896
+ fs7.accessSync(resolved, fs7.constants.W_OK);
16883
17897
  } catch (error) {
16884
17898
  throw new Error(`${label} build-library directory is not writable: ${resolved}. ${error.message}`);
16885
17899
  }
@@ -16890,27 +17904,27 @@ var init_tools = __esm({
16890
17904
  if (_RobloxStudioTools._cachedLibraryPath)
16891
17905
  return _RobloxStudioTools._cachedLibraryPath;
16892
17906
  const overridePath = process.env.BLOXFORGE_BUILD_LIBRARY || process.env.ROBLOXSTUDIO_MCP_BUILD_LIBRARY || process.env.BUILD_LIBRARY_PATH;
16893
- const cwd = path6.resolve(process.cwd());
17907
+ const cwd = path8.resolve(process.cwd());
16894
17908
  const projectRoot = _RobloxStudioTools.findProjectRoot(cwd);
16895
- const newHomeLibraryPath = path6.join(os4.homedir(), ".bloxforge", "build-library");
16896
- const legacyHomeLibraryPath = path6.join(os4.homedir(), ".robloxstudio-mcp", "build-library");
16897
- const homeLibraryPath = fs5.existsSync(newHomeLibraryPath) ? newHomeLibraryPath : fs5.existsSync(legacyHomeLibraryPath) ? legacyHomeLibraryPath : newHomeLibraryPath;
16898
- const projectLibraryPath = projectRoot ? path6.join(projectRoot, "build-library") : null;
16899
- const cwdLibraryPath = path6.join(cwd, "build-library");
17909
+ const newHomeLibraryPath = path8.join(os6.homedir(), ".bloxforge", "build-library");
17910
+ const legacyHomeLibraryPath = path8.join(os6.homedir(), ".robloxstudio-mcp", "build-library");
17911
+ const homeLibraryPath = fs7.existsSync(newHomeLibraryPath) ? newHomeLibraryPath : fs7.existsSync(legacyHomeLibraryPath) ? legacyHomeLibraryPath : newHomeLibraryPath;
17912
+ const projectLibraryPath = projectRoot ? path8.join(projectRoot, "build-library") : null;
17913
+ const cwdLibraryPath = path8.join(cwd, "build-library");
16900
17914
  let result;
16901
17915
  if (overridePath) {
16902
17916
  result = _RobloxStudioTools.ensureWritableDirectory(overridePath, "override");
16903
17917
  } else {
16904
17918
  const existing = [projectLibraryPath, cwdLibraryPath].find((c) => c && _RobloxStudioTools.isDirectory(c) && (() => {
16905
17919
  try {
16906
- fs5.accessSync(c, fs5.constants.W_OK);
17920
+ fs7.accessSync(c, fs7.constants.W_OK);
16907
17921
  return true;
16908
17922
  } catch {
16909
17923
  return false;
16910
17924
  }
16911
17925
  })());
16912
17926
  if (existing) {
16913
- result = path6.resolve(existing);
17927
+ result = path8.resolve(existing);
16914
17928
  } else if (projectLibraryPath) {
16915
17929
  try {
16916
17930
  result = _RobloxStudioTools.ensureWritableDirectory(projectLibraryPath, "project-root");
@@ -16937,12 +17951,12 @@ var init_tools = __esm({
16937
17951
  if (response && response.success && response.buildData) {
16938
17952
  const buildData = response.buildData;
16939
17953
  const buildId = buildData.id || `${style}/exported`;
16940
- const filePath = path6.join(_RobloxStudioTools.findLibraryPath(), `${buildId}.json`);
16941
- const dirPath = path6.dirname(filePath);
16942
- if (!fs5.existsSync(dirPath)) {
16943
- fs5.mkdirSync(dirPath, { recursive: true });
17954
+ const filePath = path8.join(_RobloxStudioTools.findLibraryPath(), `${buildId}.json`);
17955
+ const dirPath = path8.dirname(filePath);
17956
+ if (!fs7.existsSync(dirPath)) {
17957
+ fs7.mkdirSync(dirPath, { recursive: true });
16944
17958
  }
16945
- fs5.writeFileSync(filePath, JSON.stringify(buildData, null, 2));
17959
+ fs7.writeFileSync(filePath, JSON.stringify(buildData, null, 2));
16946
17960
  response.savedTo = filePath;
16947
17961
  }
16948
17962
  return {
@@ -17039,12 +18053,12 @@ var init_tools = __esm({
17039
18053
  const normalizedParts = this.normalizeBuildParts(parts, new Set(Object.keys(normalizedPalette)));
17040
18054
  const computedBounds = bounds || this.computeBounds(normalizedParts);
17041
18055
  const buildData = { id, style, bounds: computedBounds, palette: normalizedPalette, parts: normalizedParts };
17042
- const filePath = path6.join(_RobloxStudioTools.findLibraryPath(), `${id}.json`);
17043
- const dirPath = path6.dirname(filePath);
17044
- if (!fs5.existsSync(dirPath)) {
17045
- fs5.mkdirSync(dirPath, { recursive: true });
18056
+ const filePath = path8.join(_RobloxStudioTools.findLibraryPath(), `${id}.json`);
18057
+ const dirPath = path8.dirname(filePath);
18058
+ if (!fs7.existsSync(dirPath)) {
18059
+ fs7.mkdirSync(dirPath, { recursive: true });
17046
18060
  }
17047
- fs5.writeFileSync(filePath, JSON.stringify(buildData, null, 2));
18061
+ fs7.writeFileSync(filePath, JSON.stringify(buildData, null, 2));
17048
18062
  return {
17049
18063
  content: [
17050
18064
  {
@@ -17098,12 +18112,12 @@ var init_tools = __esm({
17098
18112
  };
17099
18113
  if (seed !== void 0)
17100
18114
  buildData.generatorSeed = seed;
17101
- const filePath = path6.join(_RobloxStudioTools.findLibraryPath(), `${id}.json`);
17102
- const dirPath = path6.dirname(filePath);
17103
- if (!fs5.existsSync(dirPath)) {
17104
- fs5.mkdirSync(dirPath, { recursive: true });
18115
+ const filePath = path8.join(_RobloxStudioTools.findLibraryPath(), `${id}.json`);
18116
+ const dirPath = path8.dirname(filePath);
18117
+ if (!fs7.existsSync(dirPath)) {
18118
+ fs7.mkdirSync(dirPath, { recursive: true });
17105
18119
  }
17106
- fs5.writeFileSync(filePath, JSON.stringify(buildData, null, 2));
18120
+ fs7.writeFileSync(filePath, JSON.stringify(buildData, null, 2));
17107
18121
  return {
17108
18122
  content: [
17109
18123
  {
@@ -17127,17 +18141,17 @@ var init_tools = __esm({
17127
18141
  }
17128
18142
  let resolved;
17129
18143
  if (typeof buildData === "string") {
17130
- const filePath = path6.join(_RobloxStudioTools.findLibraryPath(), `${buildData}.json`);
17131
- if (!fs5.existsSync(filePath)) {
18144
+ const filePath = path8.join(_RobloxStudioTools.findLibraryPath(), `${buildData}.json`);
18145
+ if (!fs7.existsSync(filePath)) {
17132
18146
  throw new Error(`Build not found in library: ${buildData}`);
17133
18147
  }
17134
- resolved = JSON.parse(fs5.readFileSync(filePath, "utf-8"));
18148
+ resolved = JSON.parse(fs7.readFileSync(filePath, "utf-8"));
17135
18149
  } else if (buildData.id && !buildData.parts) {
17136
- const filePath = path6.join(_RobloxStudioTools.findLibraryPath(), `${buildData.id}.json`);
17137
- if (!fs5.existsSync(filePath)) {
18150
+ const filePath = path8.join(_RobloxStudioTools.findLibraryPath(), `${buildData.id}.json`);
18151
+ if (!fs7.existsSync(filePath)) {
17138
18152
  throw new Error(`Build not found in library: ${buildData.id}`);
17139
18153
  }
17140
- resolved = JSON.parse(fs5.readFileSync(filePath, "utf-8"));
18154
+ resolved = JSON.parse(fs7.readFileSync(filePath, "utf-8"));
17141
18155
  } else {
17142
18156
  resolved = buildData;
17143
18157
  }
@@ -17160,13 +18174,13 @@ var init_tools = __esm({
17160
18174
  const styles = style ? [style] : ["medieval", "modern", "nature", "scifi", "misc"];
17161
18175
  const builds = [];
17162
18176
  for (const s of styles) {
17163
- const dirPath = path6.join(libraryPath, s);
17164
- if (!fs5.existsSync(dirPath))
18177
+ const dirPath = path8.join(libraryPath, s);
18178
+ if (!fs7.existsSync(dirPath))
17165
18179
  continue;
17166
- const files = fs5.readdirSync(dirPath).filter((f) => f.endsWith(".json"));
18180
+ const files = fs7.readdirSync(dirPath).filter((f) => f.endsWith(".json"));
17167
18181
  for (const file of files) {
17168
18182
  try {
17169
- const content = fs5.readFileSync(path6.join(dirPath, file), "utf-8");
18183
+ const content = fs7.readFileSync(path8.join(dirPath, file), "utf-8");
17170
18184
  const data = JSON.parse(content);
17171
18185
  builds.push({
17172
18186
  id: data.id || `${s}/${file.replace(".json", "")}`,
@@ -17205,11 +18219,11 @@ var init_tools = __esm({
17205
18219
  if (!id) {
17206
18220
  throw new Error("Build ID is required for get_build");
17207
18221
  }
17208
- const filePath = path6.join(_RobloxStudioTools.findLibraryPath(), `${id}.json`);
17209
- if (!fs5.existsSync(filePath)) {
18222
+ const filePath = path8.join(_RobloxStudioTools.findLibraryPath(), `${id}.json`);
18223
+ if (!fs7.existsSync(filePath)) {
17210
18224
  throw new Error(`Build not found in library: ${id}`);
17211
18225
  }
17212
- const data = JSON.parse(fs5.readFileSync(filePath, "utf-8"));
18226
+ const data = JSON.parse(fs7.readFileSync(filePath, "utf-8"));
17213
18227
  const result = {
17214
18228
  id: data.id,
17215
18229
  style: data.style,
@@ -17292,11 +18306,11 @@ var init_tools = __esm({
17292
18306
  if (!buildId) {
17293
18307
  throw new Error(`Invalid ${validatedKeyPath}: model key "${modelKey}" is not defined in sceneData.models`);
17294
18308
  }
17295
- const filePath = path6.join(libraryPath, `${buildId}.json`);
17296
- if (!fs5.existsSync(filePath)) {
18309
+ const filePath = path8.join(libraryPath, `${buildId}.json`);
18310
+ if (!fs7.existsSync(filePath)) {
17297
18311
  throw new Error(`Build not found in library: ${buildId}`);
17298
18312
  }
17299
- const content = fs5.readFileSync(filePath, "utf-8");
18313
+ const content = fs7.readFileSync(filePath, "utf-8");
17300
18314
  const buildData = JSON.parse(content);
17301
18315
  const buildName = buildId.split("/").pop() || buildId;
17302
18316
  expandedBuilds.push({
@@ -17611,10 +18625,10 @@ var init_tools = __esm({
17611
18625
  this.safety.recordOperation({ kind: "audio", summary: `sound ${options.soundId} under ${options.parentPath}` });
17612
18626
  return result;
17613
18627
  }
17614
- async audioPlaySound(path7, instance_id) {
17615
- if (!path7)
18628
+ async audioPlaySound(path9, instance_id) {
18629
+ if (!path9)
17616
18630
  throw new Error("path is required for audio_play_sound");
17617
- return this._runGeneratedLuau(buildPlaySoundLuau({ path: path7 }), instance_id);
18631
+ return this._runGeneratedLuau(buildPlaySoundLuau({ path: path9 }), instance_id);
17618
18632
  }
17619
18633
  async animationCreate(options, instance_id) {
17620
18634
  if (!options?.parentPath || options?.animationId === void 0)
@@ -17721,21 +18735,21 @@ var init_tools = __esm({
17721
18735
  if (!res.ok)
17722
18736
  return { content: [{ type: "text", text: JSON.stringify({ error: `Download failed: HTTP ${res.status} for ${options.source}` }) }] };
17723
18737
  const buf = Buffer.from(await res.arrayBuffer());
17724
- const ext = path6.extname(new URL(options.source).pathname) || ".bin";
17725
- filePath = path6.join(os4.tmpdir(), `mcp-ext-${Date.now()}${ext}`);
17726
- fs5.writeFileSync(filePath, buf);
18738
+ const ext = path8.extname(new URL(options.source).pathname) || ".bin";
18739
+ filePath = path8.join(os6.tmpdir(), `mcp-ext-${Date.now()}${ext}`);
18740
+ fs7.writeFileSync(filePath, buf);
17727
18741
  bytes = buf.length;
17728
18742
  cleanup = true;
17729
18743
  } else {
17730
- if (!fs5.existsSync(options.source))
18744
+ if (!fs7.existsSync(options.source))
17731
18745
  throw new Error(`File not found: ${options.source}`);
17732
18746
  filePath = options.source;
17733
- bytes = fs5.statSync(filePath).size;
18747
+ bytes = fs7.statSync(filePath).size;
17734
18748
  }
17735
- const sha256 = crypto.createHash("sha256").update(fs5.readFileSync(filePath)).digest("hex");
18749
+ const sha256 = crypto.createHash("sha256").update(fs7.readFileSync(filePath)).digest("hex");
17736
18750
  let uploadRaw = {};
17737
18751
  try {
17738
- const up = await this.uploadAsset(filePath, assetType, options.displayName ?? options.sourceName ?? path6.basename(filePath));
18752
+ const up = await this.uploadAsset(filePath, assetType, options.displayName ?? options.sourceName ?? path8.basename(filePath));
17739
18753
  const text = up.content?.find((c) => "text" in c)?.text ?? "{}";
17740
18754
  uploadRaw = JSON.parse(text);
17741
18755
  } catch (error) {
@@ -17743,7 +18757,7 @@ var init_tools = __esm({
17743
18757
  } finally {
17744
18758
  if (cleanup) {
17745
18759
  try {
17746
- fs5.unlinkSync(filePath);
18760
+ fs7.unlinkSync(filePath);
17747
18761
  } catch {
17748
18762
  }
17749
18763
  }
@@ -17802,10 +18816,10 @@ var init_tools = __esm({
17802
18816
  const { buffer, contentType } = await this.imageClient.generate(prompt, options ?? {});
17803
18817
  const ext = contentType.includes("png") ? "png" : "jpg";
17804
18818
  const slug = prompt.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40) || "image";
17805
- const dir = path6.resolve(process.env.ROBLOX_IMAGE_DIR ?? path6.join(process.cwd(), "generated-images"));
17806
- fs5.mkdirSync(dir, { recursive: true });
17807
- const file = path6.join(dir, `${slug}-${Date.now()}.${ext}`);
17808
- fs5.writeFileSync(file, buffer);
18819
+ const dir = path8.resolve(process.env.ROBLOX_IMAGE_DIR ?? path8.join(process.cwd(), "generated-images"));
18820
+ fs7.mkdirSync(dir, { recursive: true });
18821
+ const file = path8.join(dir, `${slug}-${Date.now()}.${ext}`);
18822
+ fs7.writeFileSync(file, buffer);
17809
18823
  return { file, bytes: buffer.length, model: options?.model ?? DEFAULT_IMAGE_MODEL };
17810
18824
  }
17811
18825
  async imageGenerate(prompt, options) {
@@ -17871,11 +18885,11 @@ var init_tools = __esm({
17871
18885
  return null;
17872
18886
  }
17873
18887
  async uploadAsset(filePath, assetType, displayName, description, userId, groupId) {
17874
- if (!fs5.existsSync(filePath)) {
18888
+ if (!fs7.existsSync(filePath)) {
17875
18889
  throw new Error(`File not found: ${filePath}`);
17876
18890
  }
17877
- const fileContent = fs5.readFileSync(filePath);
17878
- const fileName = path6.basename(filePath);
18891
+ const fileContent = fs7.readFileSync(filePath);
18892
+ const fileName = path8.basename(filePath);
17879
18893
  if (assetType === "Decal" && this.cookieClient.hasCookie()) {
17880
18894
  const result2 = await this.cookieClient.uploadDecal(fileContent, displayName, description || "");
17881
18895
  return {
@@ -17954,8 +18968,8 @@ var init_tools = __esm({
17954
18968
  async getSceneSummary(instancePath, topN, instance_id) {
17955
18969
  return this.sceneReadTools.getSceneSummary(instancePath, topN, instance_id);
17956
18970
  }
17957
- async applyMutationPlan(operations, dryRun, confirm, instance_id) {
17958
- return this.mutationTools.applyMutationPlan(operations, dryRun, confirm, instance_id);
18971
+ async applyMutationPlan(operations, dryRun, confirm, instance_id, atomic) {
18972
+ return this.mutationTools.applyMutationPlan(operations, dryRun, confirm, instance_id, atomic);
17959
18973
  }
17960
18974
  // Recipes: proven idempotent build macros. list is pure; apply runs the recipe's
17961
18975
  // Luau (creates/replaces named instances).
@@ -18002,7 +19016,7 @@ var init_tools = __esm({
18002
19016
  // state is this and how did it get here". Also readable at roblox://repro/bundle.
18003
19017
  // Track G2. ponytail: composes existing readers; no new persistence.
18004
19018
  async getReproductionBundle(instance_id) {
18005
- const parse = (r) => {
19019
+ const parse2 = (r) => {
18006
19020
  try {
18007
19021
  const t = r?.content?.[0]?.text;
18008
19022
  return typeof t === "string" ? JSON.parse(t) : {};
@@ -18011,10 +19025,10 @@ var init_tools = __esm({
18011
19025
  }
18012
19026
  };
18013
19027
  const [snapshot, recentOperations, instances, episodes] = await Promise.all([
18014
- this.getWorldSnapshot(void 0, "overview", void 0, instance_id).then(parse).catch(() => ({})),
18015
- Promise.resolve(this.getOperationHistory(25)).then(parse).catch(() => ({})),
18016
- Promise.resolve(this.getConnectedInstances()).then(parse).catch(() => ({})),
18017
- Promise.resolve(this.listEpisodes()).then(parse).catch(() => ({}))
19028
+ this.getWorldSnapshot(void 0, "overview", void 0, instance_id).then(parse2).catch(() => ({})),
19029
+ Promise.resolve(this.getOperationHistory(25)).then(parse2).catch(() => ({})),
19030
+ Promise.resolve(this.getConnectedInstances()).then(parse2).catch(() => ({})),
19031
+ Promise.resolve(this.listEpisodes()).then(parse2).catch(() => ({}))
18018
19032
  ]);
18019
19033
  return { content: [{ type: "text", text: JSON.stringify({
18020
19034
  generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
@@ -18034,17 +19048,17 @@ var init_tools = __esm({
18034
19048
  async toolCatalogSearch(body) {
18035
19049
  return this.discoveryTools.toolCatalogSearch(body);
18036
19050
  }
18037
- async getWorldSnapshot(path7, level, topNPerClass, instance_id) {
18038
- return this.worldTools.getWorldSnapshot(path7, level, topNPerClass, instance_id);
19051
+ async getWorldSnapshot(path9, level, topNPerClass, instance_id) {
19052
+ return this.worldTools.getWorldSnapshot(path9, level, topNPerClass, instance_id);
18039
19053
  }
18040
- async sceneSearch(query, path7, limit, instance_id) {
18041
- return this.worldTools.sceneSearch(query, path7, limit, instance_id);
19054
+ async sceneSearch(query, path9, limit, instance_id) {
19055
+ return this.worldTools.sceneSearch(query, path9, limit, instance_id);
18042
19056
  }
18043
19057
  async getNodeBatch(paths, fields, includeChildrenCount, instance_id) {
18044
19058
  return this.worldTools.getNodeBatch(paths, fields, includeChildrenCount, instance_id);
18045
19059
  }
18046
- async getChangesSince(snapshotId, path7, instance_id) {
18047
- return this.worldTools.getChangesSince(snapshotId, path7, instance_id);
19060
+ async getChangesSince(snapshotId, path9, instance_id) {
19061
+ return this.worldTools.getChangesSince(snapshotId, path9, instance_id);
18048
19062
  }
18049
19063
  async assetPreflightInsert(assetId, instance_id) {
18050
19064
  return this.worldTools.assetPreflightInsert(assetId, instance_id);
@@ -18083,10 +19097,10 @@ var init_tools = __esm({
18083
19097
  return { content: [{ type: "text", text: JSON.stringify({ error: "plugin returned no base64 payload" }) }] };
18084
19098
  }
18085
19099
  const bytes = Buffer.from(response.base64, "base64");
18086
- const resolved = path6.resolve(outputPath);
19100
+ const resolved = path8.resolve(outputPath);
18087
19101
  try {
18088
- fs5.mkdirSync(path6.dirname(resolved), { recursive: true });
18089
- fs5.writeFileSync(resolved, bytes);
19102
+ fs7.mkdirSync(path8.dirname(resolved), { recursive: true });
19103
+ fs7.writeFileSync(resolved, bytes);
18090
19104
  } catch (err) {
18091
19105
  return { content: [{ type: "text", text: JSON.stringify({ error: `failed to write ${resolved}: ${err.message}` }) }] };
18092
19106
  }
@@ -18119,9 +19133,9 @@ var init_tools = __esm({
18119
19133
  let bytes;
18120
19134
  let sourceLabel;
18121
19135
  if (source.path !== void 0) {
18122
- const resolved = path6.resolve(source.path);
19136
+ const resolved = path8.resolve(source.path);
18123
19137
  try {
18124
- bytes = fs5.readFileSync(resolved);
19138
+ bytes = fs7.readFileSync(resolved);
18125
19139
  } catch (err) {
18126
19140
  return { content: [{ type: "text", text: JSON.stringify({ error: `failed to read ${resolved}: ${err.message}` }) }] };
18127
19141
  }
@@ -18314,7 +19328,7 @@ var init_tools = __esm({
18314
19328
  this.instanceManager.attachInstanceId(record, connected.instanceId);
18315
19329
  return wrapToolJsonText({ ...this.managedStatus(record), instance_id: connected.instanceId, connected: true });
18316
19330
  }
18317
- await new Promise((resolve6) => setTimeout(resolve6, 500));
19331
+ await new Promise((resolve7) => setTimeout(resolve7, 500));
18318
19332
  }
18319
19333
  }
18320
19334
  return wrapToolJsonText({ ...this.managedStatus(record), connected: false });
@@ -18346,17 +19360,17 @@ var init_tools = __esm({
18346
19360
  const rawSnapshotBase64 = mutable.raw_snapshot_base64;
18347
19361
  if (typeof rawSnapshotBase64 === "string") {
18348
19362
  if (typeof outputPath === "string" && outputPath !== "") {
18349
- const resolvedOutputPath = path6.resolve(outputPath);
18350
- fs5.mkdirSync(path6.dirname(resolvedOutputPath), { recursive: true });
18351
- fs5.writeFileSync(resolvedOutputPath, Buffer.from(rawSnapshotBase64, "base64"));
19363
+ const resolvedOutputPath = path8.resolve(outputPath);
19364
+ fs7.mkdirSync(path8.dirname(resolvedOutputPath), { recursive: true });
19365
+ fs7.writeFileSync(resolvedOutputPath, Buffer.from(rawSnapshotBase64, "base64"));
18352
19366
  mutable.output_path = resolvedOutputPath;
18353
19367
  }
18354
19368
  delete mutable.raw_snapshot_base64;
18355
19369
  }
18356
19370
  if (typeof summaryOutputPath === "string" && summaryOutputPath !== "") {
18357
- const resolvedSummaryPath = path6.resolve(summaryOutputPath);
18358
- fs5.mkdirSync(path6.dirname(resolvedSummaryPath), { recursive: true });
18359
- fs5.writeFileSync(resolvedSummaryPath, JSON.stringify(mutable, null, 2), "utf8");
19371
+ const resolvedSummaryPath = path8.resolve(summaryOutputPath);
19372
+ fs7.mkdirSync(path8.dirname(resolvedSummaryPath), { recursive: true });
19373
+ fs7.writeFileSync(resolvedSummaryPath, JSON.stringify(mutable, null, 2), "utf8");
18360
19374
  mutable.summary_output_path = resolvedSummaryPath;
18361
19375
  }
18362
19376
  const baselineCapture = loadMicroProfilerBaseline(request.baseline, request.baseline_path);
@@ -18389,12 +19403,11 @@ var init_proxy_bridge_service = __esm({
18389
19403
  ProxyBridgeService = class _ProxyBridgeService extends BridgeService {
18390
19404
  primaryBaseUrl;
18391
19405
  proxyInstanceId;
18392
- proxyRequestTimeout = 3e4;
18393
19406
  cachedInstances = [];
18394
19407
  refreshTimer;
18395
19408
  static REFRESH_INTERVAL_MS = 1e3;
18396
19409
  constructor(primaryBaseUrl) {
18397
- super();
19410
+ super("");
18398
19411
  this.primaryBaseUrl = primaryBaseUrl;
18399
19412
  this.proxyInstanceId = randomUUID3();
18400
19413
  this.refreshInstances();
@@ -18425,7 +19438,7 @@ var init_proxy_bridge_service = __esm({
18425
19438
  }
18426
19439
  async sendRequest(endpoint, data, targetInstanceId, targetRole) {
18427
19440
  const controller = new AbortController();
18428
- const timeoutId = setTimeout(() => controller.abort(), this.proxyRequestTimeout);
19441
+ const timeoutId = setTimeout(() => controller.abort(), resolveRequestTimeout(endpoint, this.requestTimeout));
18429
19442
  try {
18430
19443
  const response = await fetch(`${this.primaryBaseUrl}/proxy`, {
18431
19444
  method: "POST",
@@ -18441,8 +19454,11 @@ var init_proxy_bridge_service = __esm({
18441
19454
  });
18442
19455
  clearTimeout(timeoutId);
18443
19456
  if (!response.ok) {
18444
- const body = await response.text();
18445
- throw new Error(`Proxy request failed (${response.status}): ${body}`);
19457
+ const body = await response.json().catch(() => void 0);
19458
+ if (body?.outcome === "unknown" && body.requestId) {
19459
+ throw new RequestOutcomeUnknownError(body.requestId, endpoint, resolveRequestTimeout(endpoint, this.requestTimeout));
19460
+ }
19461
+ throw new Error(`Proxy request failed (${response.status}): ${body?.error ?? "Unknown error"}`);
18446
19462
  }
18447
19463
  const result = await response.json();
18448
19464
  if (result.error) {
@@ -18457,6 +19473,26 @@ var init_proxy_bridge_service = __esm({
18457
19473
  throw err;
18458
19474
  }
18459
19475
  }
19476
+ async lookupRequestStatus(requestId) {
19477
+ try {
19478
+ const response = await fetch(`${this.primaryBaseUrl}/request/${encodeURIComponent(requestId)}/status`);
19479
+ if (response.status === 404)
19480
+ return void 0;
19481
+ if (!response.ok)
19482
+ throw new Error(`Request status lookup failed (${response.status})`);
19483
+ return response.json();
19484
+ } catch {
19485
+ return void 0;
19486
+ }
19487
+ }
19488
+ async requestCancellation(requestId) {
19489
+ const response = await fetch(`${this.primaryBaseUrl}/cancel`, {
19490
+ method: "POST",
19491
+ headers: { "Content-Type": "application/json" },
19492
+ body: JSON.stringify({ requestId })
19493
+ });
19494
+ return response.ok;
19495
+ }
18460
19496
  cleanupOldRequests() {
18461
19497
  }
18462
19498
  clearAllPendingRequests() {
@@ -18688,6 +19724,12 @@ var init_setup_registry = __esm({
18688
19724
  import { Server as Server2 } from "@modelcontextprotocol/sdk/server/index.js";
18689
19725
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
18690
19726
  import { CallToolRequestSchema as CallToolRequestSchema2, ErrorCode as ErrorCode2, ListToolsRequestSchema as ListToolsRequestSchema2, ListResourcesRequestSchema as ListResourcesRequestSchema2, ListResourceTemplatesRequestSchema as ListResourceTemplatesRequestSchema2, ReadResourceRequestSchema as ReadResourceRequestSchema2, SubscribeRequestSchema as SubscribeRequestSchema2, UnsubscribeRequestSchema as UnsubscribeRequestSchema2, McpError as McpError2 } from "@modelcontextprotocol/sdk/types.js";
19727
+ function resolveBridgeHost(host = process.env.ROBLOX_STUDIO_HOST) {
19728
+ return host?.trim() || "127.0.0.1";
19729
+ }
19730
+ function isLoopbackHost(host) {
19731
+ return host === "127.0.0.1" || host === "::1" || host === "localhost";
19732
+ }
18691
19733
  function shouldUseLazyToolLoading(value) {
18692
19734
  const flag = (value ?? "").trim().toLowerCase();
18693
19735
  return !(flag === "0" || flag === "false" || flag === "off");
@@ -18722,6 +19764,7 @@ var init_server = __esm({
18722
19764
  init_structured_output();
18723
19765
  init_server_instructions();
18724
19766
  init_resources();
19767
+ init_capability_policy();
18725
19768
  BloxForgeServer = class {
18726
19769
  server;
18727
19770
  tools;
@@ -18738,8 +19781,10 @@ var init_server = __esm({
18738
19781
  lazyTools;
18739
19782
  activeToolNames;
18740
19783
  registry;
19784
+ stdioCapabilities;
18741
19785
  constructor(config) {
18742
19786
  this.config = config;
19787
+ this.stdioCapabilities = parseCapabilities(process.env.BLOXFORGE_STDIO_CAPABILITIES);
18743
19788
  this.allowedToolNames = new Set(config.tools.map((t) => t.name));
18744
19789
  this.lazyTools = shouldUseLazyToolLoading(process.env.ROBLOX_MCP_LAZY_TOOLS);
18745
19790
  this.activeToolNames = new Set(CORE_TOOLS);
@@ -18757,7 +19802,7 @@ var init_server = __esm({
18757
19802
  },
18758
19803
  instructions: SERVER_INSTRUCTIONS
18759
19804
  });
18760
- this.bridge = new BridgeService();
19805
+ this.bridge = new BridgeService("");
18761
19806
  this.tools = new RobloxStudioTools(this.bridge);
18762
19807
  this.registry = new ToolRegistry();
18763
19808
  registerContractedTools(this.registry, this.tools);
@@ -18804,6 +19849,11 @@ var init_server = __esm({
18804
19849
  if (!this.allowedToolNames.has(name)) {
18805
19850
  throw new McpError2(ErrorCode2.MethodNotFound, `Unknown tool: ${name}`);
18806
19851
  }
19852
+ const definition = this.config.tools.find((tool) => tool.name === name);
19853
+ const capability = definition ? requiredCapability(name, definition.category) : void 0;
19854
+ if (capability && this.stdioCapabilities && !this.stdioCapabilities.has(capability)) {
19855
+ throw new McpError2(ErrorCode2.InvalidRequest, `Capability required: ${capability}`);
19856
+ }
18807
19857
  try {
18808
19858
  const registryResult = await this.registry.callTool(name, this.tools, args ?? {});
18809
19859
  if (registryResult !== null && registryResult !== void 0) {
@@ -18888,7 +19938,10 @@ var init_server = __esm({
18888
19938
  }
18889
19939
  async run() {
18890
19940
  const basePort = process.env.ROBLOX_STUDIO_PORT ? parseInt(process.env.ROBLOX_STUDIO_PORT) : 58741;
18891
- const host = process.env.ROBLOX_STUDIO_HOST || "0.0.0.0";
19941
+ const host = resolveBridgeHost();
19942
+ if (!isLoopbackHost(host)) {
19943
+ console.error(`[security] Bridge is listening on ${host}. This explicit non-loopback mode exposes unauthenticated local-control endpoints.`);
19944
+ }
18892
19945
  let bridgeMode = "primary";
18893
19946
  let httpHandle;
18894
19947
  let primaryApp;
@@ -18898,6 +19951,7 @@ var init_server = __esm({
18898
19951
  primaryApp = createHttpServer(this.tools, this.bridge, this.allowedToolNames, this.config, this.registry);
18899
19952
  const result = await listenWithRetry(primaryApp, host, basePort, 1);
18900
19953
  httpHandle = result.server;
19954
+ this.bridge.enableJournal();
18901
19955
  boundPort = result.port;
18902
19956
  console.error(`HTTP server listening on ${host}:${boundPort} for Studio plugin (primary mode)`);
18903
19957
  console.error(`Streamable HTTP MCP endpoint: http://localhost:${boundPort}/mcp`);
@@ -18910,12 +19964,13 @@ var init_server = __esm({
18910
19964
  console.error(`Port ${basePort} in use - entering proxy mode (forwarding to localhost:${basePort})`);
18911
19965
  const promotionIntervalMs = parseInt(process.env.ROBLOX_STUDIO_PROXY_PROMOTION_INTERVAL_MS || "5000");
18912
19966
  promotionInterval = setInterval(async () => {
18913
- const candidateBridge = new BridgeService();
19967
+ const candidateBridge = new BridgeService("");
18914
19968
  const candidateTools = new RobloxStudioTools(candidateBridge);
18915
19969
  const candidateApp = createHttpServer(candidateTools, candidateBridge, this.allowedToolNames, this.config);
18916
19970
  try {
18917
19971
  const result = await listenWithRetry(candidateApp, host, basePort, 1);
18918
19972
  const oldBridge = this.bridge;
19973
+ candidateBridge.enableJournal();
18919
19974
  this.bridge = candidateBridge;
18920
19975
  this.tools = candidateTools;
18921
19976
  if (oldBridge instanceof ProxyBridgeService) {
@@ -18977,7 +20032,18 @@ var init_server = __esm({
18977
20032
  this.bridge.cleanupOldRequests();
18978
20033
  this.bridge.cleanupStaleInstances();
18979
20034
  }, 5e3);
20035
+ let shuttingDown = false;
20036
+ const closeHttp = (handle) => new Promise((resolve7) => {
20037
+ if (!handle) {
20038
+ resolve7();
20039
+ return;
20040
+ }
20041
+ handle.close(() => resolve7());
20042
+ });
18980
20043
  const shutdown = async () => {
20044
+ if (shuttingDown)
20045
+ return;
20046
+ shuttingDown = true;
18981
20047
  console.error("Shutting down MCP server...");
18982
20048
  clearInterval(activityInterval);
18983
20049
  clearInterval(cleanupInterval);
@@ -18986,12 +20052,14 @@ var init_server = __esm({
18986
20052
  if (this.bridge instanceof ProxyBridgeService) {
18987
20053
  this.bridge.stop();
18988
20054
  }
20055
+ if (primaryApp)
20056
+ primaryApp.setMCPServerActive(false);
20057
+ if (legacyApp)
20058
+ legacyApp.setMCPServerActive(false);
20059
+ this.bridge.clearAllPendingRequests();
18989
20060
  await this.server.close().catch(() => {
18990
20061
  });
18991
- if (httpHandle)
18992
- httpHandle.close();
18993
- if (legacyHandle)
18994
- legacyHandle.close();
20062
+ await Promise.all([closeHttp(httpHandle), closeHttp(legacyHandle)]);
18995
20063
  process.exit(0);
18996
20064
  };
18997
20065
  process.on("SIGTERM", shutdown);
@@ -19004,16 +20072,23 @@ var init_server = __esm({
19004
20072
  }
19005
20073
  });
19006
20074
 
20075
+ // ../core/dist/protocol-manifest.js
20076
+ var init_protocol_manifest = __esm({
20077
+ "../core/dist/protocol-manifest.js"() {
20078
+ "use strict";
20079
+ }
20080
+ });
20081
+
19007
20082
  // ../core/dist/install-plugin-helpers.js
19008
- import { existsSync as existsSync5, readFileSync as readFileSync6, unlinkSync as unlinkSync2 } from "fs";
20083
+ import { existsSync as existsSync7, readFileSync as readFileSync8, unlinkSync as unlinkSync2 } from "fs";
19009
20084
  import { execSync } from "child_process";
19010
- import { join as join6 } from "path";
19011
- import { homedir as homedir5 } from "os";
20085
+ import { join as join8 } from "path";
20086
+ import { homedir as homedir6 } from "os";
19012
20087
  function isWSL() {
19013
20088
  if (process.platform !== "linux")
19014
20089
  return false;
19015
20090
  try {
19016
- const v = readFileSync6("/proc/version", "utf8");
20091
+ const v = readFileSync8("/proc/version", "utf8");
19017
20092
  return /microsoft|wsl/i.test(v);
19018
20093
  } catch {
19019
20094
  return false;
@@ -19031,7 +20106,7 @@ function getWindowsUserPluginsDir() {
19031
20106
  }).toString().trim();
19032
20107
  if (!linuxPath)
19033
20108
  return null;
19034
- return join6(linuxPath, "Roblox", "Plugins");
20109
+ return join8(linuxPath, "Roblox", "Plugins");
19035
20110
  } catch {
19036
20111
  return null;
19037
20112
  }
@@ -19040,7 +20115,7 @@ function getPluginsFolder() {
19040
20115
  if (process.env.MCP_PLUGINS_DIR)
19041
20116
  return process.env.MCP_PLUGINS_DIR;
19042
20117
  if (process.platform === "win32") {
19043
- return join6(process.env.LOCALAPPDATA || join6(homedir5(), "AppData", "Local"), "Roblox", "Plugins");
20118
+ return join8(process.env.LOCALAPPDATA || join8(homedir6(), "AppData", "Local"), "Roblox", "Plugins");
19044
20119
  }
19045
20120
  if (isWSL()) {
19046
20121
  const win = getWindowsUserPluginsDir();
@@ -19048,11 +20123,11 @@ function getPluginsFolder() {
19048
20123
  return win;
19049
20124
  console.warn("[install-plugin] WSL detected but could not resolve Windows %LOCALAPPDATA%. Falling back to ~/Documents/Roblox/Plugins/ - you will likely need to copy the rbxmx to /mnt/c/Users/<you>/AppData/Local/Roblox/Plugins/ manually. Set MCP_PLUGINS_DIR to skip detection.");
19050
20125
  }
19051
- return join6(homedir5(), "Documents", "Roblox", "Plugins");
20126
+ return join8(homedir6(), "Documents", "Roblox", "Plugins");
19052
20127
  }
19053
20128
  function handleVariantConflict({ pluginsFolder, otherAssetName, replace, log = console.log, warn = console.warn }) {
19054
- const otherDest = join6(pluginsFolder, otherAssetName);
19055
- if (!existsSync5(otherDest))
20129
+ const otherDest = join8(pluginsFolder, otherAssetName);
20130
+ if (!existsSync7(otherDest))
19056
20131
  return;
19057
20132
  if (replace) {
19058
20133
  try {
@@ -19092,6 +20167,8 @@ var init_dist = __esm({
19092
20167
  init_server();
19093
20168
  init_http_server();
19094
20169
  init_bridge_service();
20170
+ init_protocol_manifest();
20171
+ init_quality_tools();
19095
20172
  init_tools();
19096
20173
  init_studio_client();
19097
20174
  init_definitions();
@@ -19109,13 +20186,13 @@ __export(install_plugin_exports, {
19109
20186
  installBundledPlugin: () => installBundledPlugin,
19110
20187
  installPlugin: () => installPlugin
19111
20188
  });
19112
- import { copyFileSync as copyFileSync2, createWriteStream, existsSync as existsSync6, mkdirSync as mkdirSync7, readFileSync as readFileSync7, unlinkSync as unlinkSync3 } from "fs";
19113
- import { dirname as dirname6, join as join7 } from "path";
20189
+ import { copyFileSync as copyFileSync3, createWriteStream, existsSync as existsSync8, mkdirSync as mkdirSync8, readFileSync as readFileSync9, unlinkSync as unlinkSync3 } from "fs";
20190
+ import { dirname as dirname8, join as join9 } from "path";
19114
20191
  import { fileURLToPath } from "url";
19115
20192
  import { get } from "https";
19116
20193
  function httpsGet(url) {
19117
- return new Promise((resolve6, reject) => {
19118
- const req = get(url, { headers: { "User-Agent": "bloxforge-inspector" } }, resolve6);
20194
+ return new Promise((resolve7, reject) => {
20195
+ const req = get(url, { headers: { "User-Agent": "bloxforge-inspector" } }, resolve7);
19119
20196
  req.on("error", reject);
19120
20197
  req.setTimeout(TIMEOUT_MS, () => {
19121
20198
  req.destroy(new Error(`Request timed out after ${TIMEOUT_MS}ms`));
@@ -19133,7 +20210,7 @@ async function download(url, dest, redirects = 0) {
19133
20210
  if (res.statusCode !== 200) {
19134
20211
  throw new Error(`Download failed: HTTP ${res.statusCode}`);
19135
20212
  }
19136
- return new Promise((resolve6, reject) => {
20213
+ return new Promise((resolve7, reject) => {
19137
20214
  const file = createWriteStream(dest);
19138
20215
  const cleanup = (err) => {
19139
20216
  file.close(() => {
@@ -19147,7 +20224,7 @@ async function download(url, dest, redirects = 0) {
19147
20224
  res.pipe(file);
19148
20225
  file.on("finish", () => {
19149
20226
  file.close();
19150
- resolve6();
20227
+ resolve7();
19151
20228
  });
19152
20229
  file.on("error", cleanup);
19153
20230
  res.on("error", cleanup);
@@ -19170,8 +20247,8 @@ function prepareInstall({
19170
20247
  warn
19171
20248
  }) {
19172
20249
  const pluginsFolder = getPluginsFolder();
19173
- if (!existsSync6(pluginsFolder)) {
19174
- mkdirSync7(pluginsFolder, { recursive: true });
20250
+ if (!existsSync8(pluginsFolder)) {
20251
+ mkdirSync8(pluginsFolder, { recursive: true });
19175
20252
  }
19176
20253
  handleVariantConflict({
19177
20254
  pluginsFolder,
@@ -19183,23 +20260,23 @@ function prepareInstall({
19183
20260
  return pluginsFolder;
19184
20261
  }
19185
20262
  function bundledAssetPath() {
19186
- const currentDir = dirname6(fileURLToPath(import.meta.url));
20263
+ const currentDir = dirname8(fileURLToPath(import.meta.url));
19187
20264
  const candidates = [
19188
- join7(currentDir, "..", "studio-plugin", ASSET_NAME),
19189
- join7(currentDir, "..", "..", "..", "studio-plugin", ASSET_NAME)
20265
+ join9(currentDir, "..", "studio-plugin", ASSET_NAME),
20266
+ join9(currentDir, "..", "..", "..", "studio-plugin", ASSET_NAME)
19190
20267
  ];
19191
- return candidates.find((candidate) => existsSync6(candidate)) ?? null;
20268
+ return candidates.find((candidate) => existsSync8(candidate)) ?? null;
19192
20269
  }
19193
20270
  function packageVersion() {
19194
- const currentDir = dirname6(fileURLToPath(import.meta.url));
19195
- const pkg = JSON.parse(readFileSync7(join7(currentDir, "..", "package.json"), "utf8"));
20271
+ const currentDir = dirname8(fileURLToPath(import.meta.url));
20272
+ const pkg = JSON.parse(readFileSync9(join9(currentDir, "..", "package.json"), "utf8"));
19196
20273
  if (!pkg.version) {
19197
20274
  throw new Error("Package version not found");
19198
20275
  }
19199
20276
  return pkg.version;
19200
20277
  }
19201
20278
  function bundledPluginVersion(source) {
19202
- const match = readFileSync7(source, "utf8").match(/local CURRENT_VERSION = "([^"]+)"/);
20279
+ const match = readFileSync9(source, "utf8").match(/local CURRENT_VERSION = "([^"]+)"/);
19203
20280
  return match ? match[1] : null;
19204
20281
  }
19205
20282
  function assertBundledPluginVersion(source) {
@@ -19212,9 +20289,9 @@ function assertBundledPluginVersion(source) {
19212
20289
  }
19213
20290
  }
19214
20291
  function filesMatch(a, b) {
19215
- if (!existsSync6(b)) return false;
19216
- const aBytes = readFileSync7(a);
19217
- const bBytes = readFileSync7(b);
20292
+ if (!existsSync8(b)) return false;
20293
+ const aBytes = readFileSync9(a);
20294
+ const bBytes = readFileSync9(b);
19218
20295
  return aBytes.length === bBytes.length && aBytes.equals(bBytes);
19219
20296
  }
19220
20297
  async function installBundledPlugin(options = {}) {
@@ -19227,9 +20304,9 @@ async function installBundledPlugin(options = {}) {
19227
20304
  }
19228
20305
  assertBundledPluginVersion(source);
19229
20306
  const pluginsFolder = prepareInstall({ replaceVariant, log, warn });
19230
- const dest = join7(pluginsFolder, ASSET_NAME);
20307
+ const dest = join9(pluginsFolder, ASSET_NAME);
19231
20308
  if (filesMatch(source, dest)) return;
19232
- copyFileSync2(source, dest);
20309
+ copyFileSync3(source, dest);
19233
20310
  log(`Installed ${ASSET_NAME} to ${dest}`);
19234
20311
  }
19235
20312
  async function installPlugin(options = {}) {
@@ -19240,12 +20317,12 @@ async function installPlugin(options = {}) {
19240
20317
  const bundled = bundledAssetPath();
19241
20318
  if (bundled) {
19242
20319
  assertBundledPluginVersion(bundled);
19243
- const dest2 = join7(pluginsFolder, ASSET_NAME);
20320
+ const dest2 = join9(pluginsFolder, ASSET_NAME);
19244
20321
  if (filesMatch(bundled, dest2)) {
19245
20322
  log(`${ASSET_NAME} already installed.`);
19246
20323
  return;
19247
20324
  }
19248
- copyFileSync2(bundled, dest2);
20325
+ copyFileSync3(bundled, dest2);
19249
20326
  log(`Installed bundled ${ASSET_NAME} to ${dest2}`);
19250
20327
  return;
19251
20328
  }
@@ -19255,7 +20332,7 @@ async function installPlugin(options = {}) {
19255
20332
  if (!asset) {
19256
20333
  throw new Error(`${ASSET_NAME} not found in release ${release.tag_name}`);
19257
20334
  }
19258
- const dest = join7(pluginsFolder, ASSET_NAME);
20335
+ const dest = join9(pluginsFolder, ASSET_NAME);
19259
20336
  log(`Downloading ${ASSET_NAME} from ${release.tag_name}...`);
19260
20337
  await download(asset.browser_download_url, dest);
19261
20338
  log(`Installed to ${dest}`);