@chrrxs/robloxstudio-mcp 3.1.0 → 3.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1939,10 +1939,82 @@ import { serveStdio } from "@modelcontextprotocol/server/stdio";
1939
1939
  // ../core/dist/http-server.js
1940
1940
  import express from "express";
1941
1941
  import http from "http";
1942
+ import { randomBytes as randomBytes2 } from "crypto";
1943
+ import { WebSocketServer } from "ws";
1942
1944
  import { toNodeHandler } from "@modelcontextprotocol/node";
1943
1945
 
1944
1946
  // ../core/dist/bridge-service.js
1945
- import { randomUUID } from "crypto";
1947
+ import { createHash, randomUUID } from "crypto";
1948
+ function isExecutionOutcome(value) {
1949
+ return value === "success" || value === "error" || value === "not_executed" || value === "unknown";
1950
+ }
1951
+ function isRequestStage(value) {
1952
+ return value === "queued" || value === "dispatched" || value === "executing" || value === "response_delivery";
1953
+ }
1954
+ function handlerOutcome(response) {
1955
+ if (!response || typeof response !== "object")
1956
+ return "success";
1957
+ if ("error" in response && response.error !== void 0 && response.error !== null || "success" in response && response.success === false || "ok" in response && response.ok === false)
1958
+ return "error";
1959
+ if ("summary" in response && response.summary && typeof response.summary === "object" && "failed" in response.summary && typeof response.summary.failed === "number" && response.summary.failed > 0)
1960
+ return "error";
1961
+ return "success";
1962
+ }
1963
+ function observations(status) {
1964
+ return {
1965
+ executionStartedAt: status.executionStartedAt,
1966
+ executionCompletedAt: status.executionCompletedAt,
1967
+ executionOutcome: status.executionOutcome,
1968
+ connectionLostAt: status.connectionLostAt,
1969
+ connectionRestoredAt: status.connectionRestoredAt
1970
+ };
1971
+ }
1972
+ var RequestFailure = class extends Error {
1973
+ code;
1974
+ details;
1975
+ constructor(message, code, details) {
1976
+ super(message);
1977
+ this.code = code;
1978
+ this.details = details;
1979
+ this.name = "RequestFailure";
1980
+ }
1981
+ };
1982
+ function parseObservations(value) {
1983
+ const result = {};
1984
+ if ("executionOutcome" in value) {
1985
+ if (!isExecutionOutcome(value.executionOutcome))
1986
+ throw new Error("Invalid execution outcome");
1987
+ result.executionOutcome = value.executionOutcome;
1988
+ }
1989
+ for (const key of ["executionStartedAt", "executionCompletedAt", "connectionLostAt", "connectionRestoredAt"]) {
1990
+ if (!(key in value))
1991
+ continue;
1992
+ const timestamp = Reflect.get(value, key);
1993
+ if (typeof timestamp !== "number" || !Number.isFinite(timestamp))
1994
+ throw new Error("Invalid observation timestamp");
1995
+ result[key] = timestamp;
1996
+ }
1997
+ return result;
1998
+ }
1999
+ function parseFailureDetails(value, identity) {
2000
+ if (!value || typeof value !== "object" || Array.isArray(value))
2001
+ return void 0;
2002
+ const isHttpRejection = "transportStage" in value && value.transportStage === "http_receive";
2003
+ const requestId = "requestId" in value ? value.requestId : isHttpRejection ? identity.requestId : void 0;
2004
+ const targetPeerId = "targetPeerId" in value ? value.targetPeerId : isHttpRejection ? identity.targetPeerId : void 0;
2005
+ if (typeof requestId !== "string" || typeof targetPeerId !== "string" || requestId !== identity.requestId || targetPeerId !== identity.targetPeerId || !("stage" in value) || !isRequestStage(value.stage) || !("outcome" in value) || value.outcome !== "not_executed" && value.outcome !== "unknown")
2006
+ return void 0;
2007
+ return {
2008
+ requestId,
2009
+ targetPeerId,
2010
+ stage: value.stage,
2011
+ outcome: value.outcome,
2012
+ ...parseObservations(value),
2013
+ ..."bytes" in value && typeof value.bytes === "number" ? { bytes: value.bytes } : {},
2014
+ ..."limitBytes" in value && typeof value.limitBytes === "number" ? { limitBytes: value.limitBytes } : {},
2015
+ ..."transportStage" in value && (value.transportStage === "server_send" || value.transportStage === "proxy_send" || value.transportStage === "http_receive") ? { transportStage: value.transportStage } : {}
2016
+ };
2017
+ }
1946
2018
  var RoutingFailure = class extends Error {
1947
2019
  routingError;
1948
2020
  constructor(routingError) {
@@ -1970,11 +2042,16 @@ function toPublicPeer(peer) {
1970
2042
  };
1971
2043
  }
1972
2044
  var STALE_PEER_MS = 3e4;
1973
- var ACCEPTED_REQUEST_TOMBSTONE_TTL_MS = 6e4;
1974
- var MAX_ACCEPTED_REQUEST_TOMBSTONES = 4096;
2045
+ var OPERATION_RETENTION_MS = 5 * 6e4;
2046
+ var MAX_OPERATION_RECORDS = 32768;
2047
+ var MAX_RETAINED_RESULTS = 1024;
2048
+ var MAX_RETAINED_RESULT_BYTES = 64 * 1024 * 1024;
2049
+ var MAX_REQUEST_BYTES = 64 * 1024 * 1024;
2050
+ var MAX_PENDING_REQUESTS = 1024;
2051
+ var MAX_PENDING_REQUEST_BYTES = 64 * 1024 * 1024;
1975
2052
  var CANCELLATION_TOMBSTONE_TTL_MS = 6e4;
1976
2053
  var MAX_CANCELLATION_TOMBSTONES = 4096;
1977
- var MAX_OUTSTANDING_REQUESTS_PER_DELIVERY_OWNER = 4;
2054
+ var MAX_OUTSTANDING_REQUESTS_PER_TRANSPORT = 4;
1978
2055
  function roleOrder(role) {
1979
2056
  if (role === "edit")
1980
2057
  return 0;
@@ -2005,7 +2082,10 @@ function copyGroup(group) {
2005
2082
  }
2006
2083
  var BridgeService = class {
2007
2084
  pendingRequests = /* @__PURE__ */ new Map();
2008
- acceptedRequestIds = /* @__PURE__ */ new Map();
2085
+ operations = /* @__PURE__ */ new Map();
2086
+ retainedResults = /* @__PURE__ */ new Map();
2087
+ retainedResultBytes = 0;
2088
+ pendingRequestBytes = 0;
2009
2089
  pendingCancellations = /* @__PURE__ */ new Map();
2010
2090
  peersById = /* @__PURE__ */ new Map();
2011
2091
  multiplayerGroupsById = /* @__PURE__ */ new Map();
@@ -2048,13 +2128,25 @@ var BridgeService = class {
2048
2128
  this.deliveryOwnersByTransportPeer.set(transportPeerId, owners);
2049
2129
  }
2050
2130
  owners.add(owner);
2131
+ for (const operation of this.operations.values()) {
2132
+ if (operation.transportPeerId === transportPeerId && operation.status.state !== "settled" && operation.status.connectionLostAt !== void 0 && operation.status.connectionRestoredAt === void 0) {
2133
+ operation.status.connectionRestoredAt = Date.now();
2134
+ }
2135
+ }
2051
2136
  return;
2052
2137
  }
2053
2138
  if (!owners)
2054
2139
  return;
2055
2140
  owners.delete(owner);
2056
- if (owners.size === 0)
2141
+ if (owners.size === 0) {
2057
2142
  this.deliveryOwnersByTransportPeer.delete(transportPeerId);
2143
+ for (const operation of this.operations.values()) {
2144
+ if (operation.transportPeerId !== transportPeerId || operation.status.state === "settled")
2145
+ continue;
2146
+ operation.status.connectionLostAt = Date.now();
2147
+ delete operation.status.connectionRestoredAt;
2148
+ }
2149
+ }
2058
2150
  }
2059
2151
  notifyRequestAvailable(transportPeerId) {
2060
2152
  for (const listener of this.requestAvailableListeners) {
@@ -2285,8 +2377,7 @@ var BridgeService = class {
2285
2377
  if (request.targetPeerId !== peerId)
2286
2378
  continue;
2287
2379
  const deliveryTransportPeerId = request.lastDeliveryTransportPeerId;
2288
- this.removePendingRequest(request);
2289
- request.reject(new Error(`Target Peer "${peerId}" disconnected`));
2380
+ this.endRequestWaiter(request, "disconnected", `Target Peer "${peerId}" disconnected`);
2290
2381
  if (deliveryTransportPeerId)
2291
2382
  this.notifyRequestAvailable(deliveryTransportPeerId);
2292
2383
  }
@@ -2545,44 +2636,126 @@ var BridgeService = class {
2545
2636
  const peers = this.getPeers().filter((peer) => this.peerScopeKey(peer) === onlyScope);
2546
2637
  return this.resolveWithinScope(peers, input.target, errorData);
2547
2638
  }
2548
- sendRequest(endpoint, data, targetPeerId, timeoutMs = this.requestTimeout, signal) {
2549
- const requestId = randomUUID();
2639
+ sendRequest(endpoint, data, targetPeerId, timeoutMs = this.requestTimeout, signal, operationId) {
2640
+ const requestId = operationId ?? randomUUID();
2550
2641
  const effectiveTimeoutMs = Math.max(1, timeoutMs);
2551
- if (signal?.aborted)
2552
- return Promise.reject(new Error("Request aborted"));
2642
+ const details = { requestId, targetPeerId, stage: "queued", outcome: "not_executed", executionOutcome: "not_executed" };
2643
+ if (typeof requestId !== "string" || requestId.trim().length === 0 || requestId.length > 128) {
2644
+ return Promise.reject(new RequestFailure("operationId must be a nonempty string of at most 128 characters", "invalid_operation_id", details));
2645
+ }
2646
+ if (signal?.aborted) {
2647
+ return Promise.reject(new RequestFailure(`Request aborted: ${requestId}; queued; not_executed`, "request_aborted", details));
2648
+ }
2649
+ let requestBytes;
2650
+ let fingerprint;
2651
+ try {
2652
+ const target2 = this.getPeerById(targetPeerId);
2653
+ fingerprint = createHash("sha256").update(JSON.stringify({ targetPeerId, endpoint, data })).digest("hex");
2654
+ requestBytes = Buffer.byteLength(JSON.stringify({
2655
+ kind: "request",
2656
+ requestId,
2657
+ peerId: targetPeerId,
2658
+ target: target2?.role,
2659
+ endpoint,
2660
+ data: data ?? null,
2661
+ remainingMs: effectiveTimeoutMs
2662
+ }));
2663
+ } catch {
2664
+ return Promise.reject(new RequestFailure(`Request ${requestId} cannot be serialized; queued; not_executed`, "request_serialization_failed", details));
2665
+ }
2666
+ this.pruneOperations(Date.now());
2667
+ const existing = this.operations.get(requestId);
2668
+ if (existing) {
2669
+ if (existing.fingerprint !== fingerprint) {
2670
+ return Promise.reject(new RequestFailure(`Request ${requestId} already identifies a different operation; existing operation ${existing.status.stage}; not replayed`, "operation_id_collision", { requestId, targetPeerId: existing.status.targetPeerId, stage: existing.status.stage, outcome: "unknown", ...observations(existing.status) }));
2671
+ }
2672
+ const pending = this.pendingRequests.get(requestId);
2673
+ if (pending)
2674
+ return pending.promise;
2675
+ const status = this.getRequestStatus(requestId);
2676
+ if (status.state === "settled" && !status.resultUnavailable) {
2677
+ const error = status.error;
2678
+ if (error && typeof error === "object" && "name" in error && error.name === "RequestFailure" && "code" in error && typeof error.code === "string" && "message" in error && typeof error.message === "string" && "details" in error) {
2679
+ const failureDetails = parseFailureDetails(error.details, { requestId, targetPeerId });
2680
+ if (failureDetails)
2681
+ return Promise.reject(new RequestFailure(error.message, error.code, failureDetails));
2682
+ }
2683
+ return Object.hasOwn(status, "error") ? Promise.reject(status.error) : Promise.resolve(status.response);
2684
+ }
2685
+ return Promise.reject(new RequestFailure(`Request ${requestId} already exists: ${status.state}; ${status.stage}; ${status.outcome}; use get_request_status; not replayed`, "operation_not_replayed", { requestId, targetPeerId, stage: status.stage, outcome: status.executionOutcome === "not_executed" ? "not_executed" : "unknown", ...observations(status) }));
2686
+ }
2687
+ if (requestBytes > MAX_REQUEST_BYTES) {
2688
+ return Promise.reject(new RequestFailure(`Request ${requestId} is ${requestBytes} bytes at server_send; limit ${MAX_REQUEST_BYTES} bytes; queued; not_executed`, "request_too_large", { ...details, bytes: requestBytes, limitBytes: MAX_REQUEST_BYTES, transportStage: "server_send" }));
2689
+ }
2690
+ if (this.pendingRequests.size >= MAX_PENDING_REQUESTS || this.pendingRequestBytes + requestBytes > MAX_PENDING_REQUEST_BYTES) {
2691
+ return Promise.reject(new RequestFailure(`Request ${requestId} rejected at admission: pending capacity exceeded (${this.pendingRequests.size}/${MAX_PENDING_REQUESTS} requests, ${this.pendingRequestBytes + requestBytes}/${MAX_PENDING_REQUEST_BYTES} bytes); queued; not_executed`, "request_capacity_exceeded", { ...details, bytes: this.pendingRequestBytes + requestBytes, limitBytes: MAX_PENDING_REQUEST_BYTES }));
2692
+ }
2553
2693
  const { promise, resolve: resolve5, reject } = Promise.withResolvers();
2554
- const cancelPending = (reason, error) => {
2694
+ const abortListener = () => {
2555
2695
  const pending = this.pendingRequests.get(requestId);
2556
- if (!pending || !this.removePendingRequest(pending))
2557
- return;
2558
- this.notifyRequestCancelled(pending, reason);
2559
- pending.reject(error);
2696
+ if (pending)
2697
+ this.endRequestWaiter(pending, "aborted", "Request aborted");
2560
2698
  };
2561
- const timeoutId = setTimeout(() => cancelPending("timeout", new Error("Request timeout")), effectiveTimeoutMs);
2562
- const abortListener = () => cancelPending("aborted", new Error("Request aborted"));
2699
+ const timeoutId = setTimeout(() => {
2700
+ const pending = this.pendingRequests.get(requestId);
2701
+ if (pending)
2702
+ this.endRequestWaiter(pending, "timed_out", "Request timeout");
2703
+ }, effectiveTimeoutMs);
2704
+ const now = Date.now();
2563
2705
  const request = {
2564
2706
  id: requestId,
2565
2707
  endpoint,
2566
2708
  data,
2567
2709
  targetPeerId,
2568
- timestamp: Date.now(),
2710
+ timestamp: now,
2569
2711
  resolve: resolve5,
2570
2712
  reject,
2713
+ promise,
2571
2714
  timeoutId,
2572
2715
  timeoutMs: effectiveTimeoutMs,
2716
+ requestBytes,
2573
2717
  abortSignal: signal,
2574
2718
  abortListener
2575
2719
  };
2576
2720
  this.pendingRequests.set(requestId, request);
2721
+ this.pendingRequestBytes += requestBytes;
2722
+ this.operations.set(requestId, {
2723
+ status: { requestId, targetPeerId, queuedAt: now, stage: "queued", state: "pending", outcome: "pending", executionOutcome: "unknown" },
2724
+ fingerprint,
2725
+ updatedAt: now,
2726
+ resultBytes: 0
2727
+ });
2728
+ this.pruneOperations(now);
2577
2729
  signal?.addEventListener("abort", abortListener, { once: true });
2578
2730
  if (signal?.aborted)
2579
2731
  abortListener();
2580
2732
  const target = this.getPeerById(targetPeerId);
2581
- if (this.pendingRequests.has(requestId) && target) {
2733
+ if (this.pendingRequests.has(requestId) && target)
2582
2734
  this.notifyRequestAvailable(target.transportPeerId);
2583
- }
2584
2735
  return promise;
2585
2736
  }
2737
+ endRequestWaiter(request, state, message) {
2738
+ if (!this.removePendingRequest(request))
2739
+ return;
2740
+ const operation = this.operations.get(request.id);
2741
+ const stage = operation?.status.stage ?? (request.lastDeliveryTransportPeerId ? "dispatched" : "queued");
2742
+ const outcome = stage === "queued" || operation?.status.executionOutcome === "not_executed" ? "not_executed" : "unknown";
2743
+ if (operation) {
2744
+ operation.status.state = state;
2745
+ operation.status.outcome = outcome;
2746
+ if (stage === "queued")
2747
+ operation.status.executionOutcome = "not_executed";
2748
+ operation.status.waiterEndedAt = Date.now();
2749
+ operation.updatedAt = Date.now();
2750
+ this.operations.delete(request.id);
2751
+ this.operations.set(request.id, operation);
2752
+ }
2753
+ if (state !== "disconnected") {
2754
+ this.notifyRequestCancelled(request, state === "timed_out" ? "timeout" : "aborted");
2755
+ }
2756
+ const connectionLost = operation?.status.connectionLostAt !== void 0 && operation.status.connectionRestoredAt === void 0;
2757
+ request.reject(new RequestFailure(`${message}: ${request.id}; ${stage}; ${outcome}${connectionLost ? "; connection lost" : ""}; waiter ended, execution is not cancelled or rolled back`, state === "timed_out" ? connectionLost ? "request_connection_lost" : "request_timeout" : `request_${state}`, { requestId: request.id, targetPeerId: request.targetPeerId, stage, outcome, ...operation ? observations(operation.status) : {} }));
2758
+ }
2586
2759
  removePendingRequest(request) {
2587
2760
  if (this.pendingRequests.get(request.id) !== request)
2588
2761
  return false;
@@ -2591,15 +2764,16 @@ var BridgeService = class {
2591
2764
  request.abortSignal.removeEventListener("abort", request.abortListener);
2592
2765
  }
2593
2766
  this.pendingRequests.delete(request.id);
2767
+ this.pendingRequestBytes -= request.requestBytes;
2594
2768
  return true;
2595
2769
  }
2596
2770
  claimNextRequestForTransport(transportPeerId, claimOwner) {
2597
2771
  let outstandingCount = 0;
2598
2772
  for (const request of this.pendingRequests.values()) {
2599
- if (request.claimOwner === claimOwner)
2773
+ if (request.lastDeliveryTransportPeerId === transportPeerId)
2600
2774
  outstandingCount++;
2601
2775
  }
2602
- if (outstandingCount >= MAX_OUTSTANDING_REQUESTS_PER_DELIVERY_OWNER)
2776
+ if (outstandingCount >= MAX_OUTSTANDING_REQUESTS_PER_TRANSPORT)
2603
2777
  return null;
2604
2778
  let oldestRequest;
2605
2779
  for (const request of this.pendingRequests.values()) {
@@ -2618,6 +2792,13 @@ var BridgeService = class {
2618
2792
  return null;
2619
2793
  oldestRequest.claimOwner = claimOwner;
2620
2794
  oldestRequest.lastDeliveryTransportPeerId = transportPeerId;
2795
+ const operation = this.operations.get(oldestRequest.id);
2796
+ if (operation) {
2797
+ operation.transportPeerId = transportPeerId;
2798
+ operation.status.stage = "dispatched";
2799
+ operation.status.dispatchedAt = Date.now();
2800
+ operation.updatedAt = Date.now();
2801
+ }
2621
2802
  return {
2622
2803
  requestId: oldestRequest.id,
2623
2804
  peerId: oldestRequest.targetPeerId,
@@ -2640,14 +2821,6 @@ var BridgeService = class {
2640
2821
  }
2641
2822
  releaseDeliveryClaims(claimOwner) {
2642
2823
  const transportPeerIds = /* @__PURE__ */ new Set();
2643
- for (const request of this.pendingRequests.values()) {
2644
- if (request.claimOwner !== claimOwner)
2645
- continue;
2646
- request.claimOwner = void 0;
2647
- const peer = this.getPeerById(request.targetPeerId);
2648
- if (peer)
2649
- transportPeerIds.add(peer.transportPeerId);
2650
- }
2651
2824
  for (const cancellation of this.pendingCancellations.values()) {
2652
2825
  if (cancellation.claimOwner !== claimOwner)
2653
2826
  continue;
@@ -2657,38 +2830,169 @@ var BridgeService = class {
2657
2830
  for (const transportPeerId of transportPeerIds)
2658
2831
  this.notifyRequestAvailable(transportPeerId);
2659
2832
  }
2833
+ ownedOperation(transportPeerId, requestId) {
2834
+ this.pruneOperations(Date.now());
2835
+ const operation = this.operations.get(requestId);
2836
+ if (!operation || operation.transportPeerId !== transportPeerId)
2837
+ return void 0;
2838
+ const peer = this.getPeerById(operation.status.targetPeerId);
2839
+ const transport = this.getPeerById(transportPeerId);
2840
+ return peer?.transportPeerId === transportPeerId && transport?.transportPeerId === transportPeerId ? operation : void 0;
2841
+ }
2842
+ observeTransportProgress(transportPeerId, requestId, phase, outcome) {
2843
+ const operation = this.ownedOperation(transportPeerId, requestId);
2844
+ if (!operation || operation.status.state === "settled" || operation.status.stage === "response_delivery" || phase === "executing" && operation.status.stage !== "dispatched")
2845
+ return;
2846
+ const now = Date.now();
2847
+ operation.status.stage = phase;
2848
+ if (phase === "executing")
2849
+ operation.status.executionStartedAt = now;
2850
+ else {
2851
+ operation.status.executionOutcome = outcome ?? "unknown";
2852
+ if (outcome !== "not_executed")
2853
+ operation.status.executionCompletedAt = now;
2854
+ }
2855
+ operation.updatedAt = now;
2856
+ this.operations.delete(requestId);
2857
+ this.operations.set(requestId, operation);
2858
+ }
2859
+ settleTransportResponse(transportPeerId, requestId, response, error, executionOutcome) {
2860
+ const operation = this.ownedOperation(transportPeerId, requestId);
2861
+ if (!operation)
2862
+ return "unknown";
2863
+ if (error !== void 0 && executionOutcome !== void 0) {
2864
+ error = new RequestFailure(typeof error === "string" ? error : "Studio response failed", "studio_response_error", {
2865
+ requestId,
2866
+ targetPeerId: operation.status.targetPeerId,
2867
+ ...observations(operation.status),
2868
+ executionCompletedAt: executionOutcome === "not_executed" ? void 0 : operation.status.executionCompletedAt ?? Date.now(),
2869
+ stage: "response_delivery",
2870
+ outcome: executionOutcome === "not_executed" ? "not_executed" : "unknown",
2871
+ executionOutcome
2872
+ });
2873
+ }
2874
+ return this.recordResponse(requestId, response, error, executionOutcome);
2875
+ }
2876
+ /** Trusted in-process settlement; transport handlers must use settleTransportResponse. */
2660
2877
  resolveRequest(requestId, response) {
2661
- return this.settleRequest(requestId, (request) => request.resolve(response));
2878
+ return this.recordResponse(requestId, response);
2662
2879
  }
2880
+ /** Trusted in-process settlement; transport handlers must use settleTransportResponse. */
2663
2881
  rejectRequest(requestId, error) {
2664
- return this.settleRequest(requestId, (request) => request.reject(error));
2882
+ return this.recordResponse(requestId, void 0, error);
2665
2883
  }
2666
- settleRequest(requestId, settle) {
2884
+ recordResponse(requestId, response, error, executionOutcome) {
2667
2885
  const now = Date.now();
2668
- this.pruneAcceptedRequestIds(now);
2886
+ this.pruneOperations(now);
2887
+ const operation = this.operations.get(requestId);
2888
+ if (!operation)
2889
+ return "unknown";
2890
+ if (operation.status.state === "settled")
2891
+ return "already_settled";
2892
+ const hasError = error !== void 0;
2893
+ operation.status.state = "settled";
2894
+ const localRejection = error instanceof RequestFailure && error.details.transportStage === "server_send";
2895
+ const responseOutcome = handlerOutcome(response);
2896
+ const completedOutcome = !hasError && responseOutcome === "error" ? "error" : executionOutcome ?? (localRejection ? "not_executed" : hasError ? operation.status.executionOutcome === "success" ? "success" : "error" : responseOutcome);
2897
+ operation.status.executionOutcome = completedOutcome;
2898
+ operation.status.outcome = hasError || completedOutcome === "error" || completedOutcome === "not_executed" ? "error" : "success";
2899
+ if (!localRejection) {
2900
+ operation.status.stage = "response_delivery";
2901
+ if (completedOutcome !== "not_executed")
2902
+ operation.status.executionCompletedAt ??= now;
2903
+ }
2904
+ operation.status.settledAt = now;
2905
+ operation.updatedAt = now;
2906
+ try {
2907
+ const recordedError = error instanceof Error ? { ...error, name: error.name, message: error.message } : error;
2908
+ const serialized = JSON.stringify(hasError ? { error: recordedError } : { response });
2909
+ const bytes = Buffer.byteLength(serialized);
2910
+ if (bytes > MAX_RETAINED_RESULT_BYTES) {
2911
+ operation.status.resultUnavailable = { reason: "size_limit", bytes, limitBytes: MAX_RETAINED_RESULT_BYTES };
2912
+ } else {
2913
+ operation.serializedResult = serialized;
2914
+ operation.resultBytes = bytes;
2915
+ this.retainedResultBytes += bytes;
2916
+ this.retainedResults.set(requestId, operation);
2917
+ }
2918
+ } catch {
2919
+ operation.status.resultUnavailable = { reason: "serialization_failed", limitBytes: MAX_RETAINED_RESULT_BYTES };
2920
+ }
2921
+ this.operations.delete(requestId);
2922
+ this.operations.set(requestId, operation);
2669
2923
  const request = this.pendingRequests.get(requestId);
2670
- if (!request)
2671
- return this.acceptedRequestIds.has(requestId) ? "already_settled" : "unknown";
2672
- const deliveryTransportPeerId = request.lastDeliveryTransportPeerId;
2673
- this.removePendingRequest(request);
2674
- this.acceptedRequestIds.set(requestId, now);
2675
- this.pruneAcceptedRequestIds(now);
2676
- settle(request);
2677
- if (deliveryTransportPeerId)
2678
- this.notifyRequestAvailable(deliveryTransportPeerId);
2924
+ if (request && this.removePendingRequest(request)) {
2925
+ if (hasError)
2926
+ request.reject(error);
2927
+ else
2928
+ request.resolve(response);
2929
+ }
2930
+ this.pendingCancellations.delete(requestId);
2931
+ this.pruneOperations(now);
2932
+ if (operation.transportPeerId)
2933
+ this.notifyRequestAvailable(operation.transportPeerId);
2679
2934
  return "accepted";
2680
2935
  }
2681
- pruneAcceptedRequestIds(now) {
2682
- for (const [requestId, acceptedAt] of this.acceptedRequestIds) {
2683
- if (now - acceptedAt < ACCEPTED_REQUEST_TOMBSTONE_TTL_MS)
2936
+ getRequestStatus(requestId) {
2937
+ this.pruneOperations(Date.now());
2938
+ const operation = this.operations.get(requestId);
2939
+ if (!operation)
2940
+ return void 0;
2941
+ const status = { ...operation.status };
2942
+ if (status.resultUnavailable)
2943
+ status.resultUnavailable = { ...status.resultUnavailable };
2944
+ if (operation.serializedResult !== void 0) {
2945
+ const result = JSON.parse(operation.serializedResult);
2946
+ if (result && typeof result === "object") {
2947
+ if ("response" in result)
2948
+ status.response = result.response;
2949
+ if ("error" in result)
2950
+ status.error = result.error;
2951
+ }
2952
+ }
2953
+ return status;
2954
+ }
2955
+ async getRequestStatusEverywhere(requestId) {
2956
+ return this.getRequestStatus(requestId);
2957
+ }
2958
+ pruneOperations(now) {
2959
+ for (const [requestId, operation] of this.operations) {
2960
+ if (this.pendingRequests.has(requestId))
2961
+ continue;
2962
+ if (now - operation.updatedAt < OPERATION_RETENTION_MS)
2963
+ break;
2964
+ this.operations.delete(requestId);
2965
+ this.retainedResults.delete(requestId);
2966
+ this.retainedResultBytes -= operation.resultBytes;
2967
+ }
2968
+ while (this.operations.size > MAX_OPERATION_RECORDS) {
2969
+ let removed = false;
2970
+ for (const [requestId, operation] of this.operations) {
2971
+ if (this.pendingRequests.has(requestId))
2972
+ continue;
2973
+ this.operations.delete(requestId);
2974
+ this.retainedResults.delete(requestId);
2975
+ this.retainedResultBytes -= operation.resultBytes;
2976
+ removed = true;
2977
+ break;
2978
+ }
2979
+ if (!removed)
2684
2980
  break;
2685
- this.acceptedRequestIds.delete(requestId);
2686
2981
  }
2687
- while (this.acceptedRequestIds.size > MAX_ACCEPTED_REQUEST_TOMBSTONES) {
2688
- const oldestRequestId = this.acceptedRequestIds.keys().next().value;
2689
- if (oldestRequestId === void 0)
2982
+ if (this.retainedResults.size <= MAX_RETAINED_RESULTS && this.retainedResultBytes <= MAX_RETAINED_RESULT_BYTES)
2983
+ return;
2984
+ for (const [requestId, operation] of this.retainedResults) {
2985
+ operation.status.resultUnavailable = {
2986
+ reason: "retention_capacity",
2987
+ bytes: operation.resultBytes,
2988
+ limitBytes: MAX_RETAINED_RESULT_BYTES
2989
+ };
2990
+ this.retainedResultBytes -= operation.resultBytes;
2991
+ operation.resultBytes = 0;
2992
+ operation.serializedResult = void 0;
2993
+ this.retainedResults.delete(requestId);
2994
+ if (this.retainedResults.size <= MAX_RETAINED_RESULTS && this.retainedResultBytes <= MAX_RETAINED_RESULT_BYTES)
2690
2995
  break;
2691
- this.acceptedRequestIds.delete(oldestRequestId);
2692
2996
  }
2693
2997
  }
2694
2998
  prunePendingCancellations(now) {
@@ -2707,16 +3011,15 @@ var BridgeService = class {
2707
3011
  cleanupOldRequests() {
2708
3012
  const now = Date.now();
2709
3013
  for (const request of this.pendingRequests.values()) {
2710
- if (now - request.timestamp > request.timeoutMs && this.removePendingRequest(request)) {
2711
- this.notifyRequestCancelled(request, "timeout");
2712
- request.reject(new Error("Request timeout"));
3014
+ if (now - request.timestamp >= request.timeoutMs) {
3015
+ this.endRequestWaiter(request, "timed_out", "Request timeout");
2713
3016
  }
2714
3017
  }
3018
+ this.pruneOperations(now);
2715
3019
  }
2716
3020
  clearAllPendingRequests() {
2717
3021
  for (const request of Array.from(this.pendingRequests.values())) {
2718
- this.removePendingRequest(request);
2719
- request.reject(new Error("Connection closed"));
3022
+ this.endRequestWaiter(request, "disconnected", "Connection closed");
2720
3023
  }
2721
3024
  this.pendingCancellations.clear();
2722
3025
  }
@@ -2981,7 +3284,7 @@ Tool descriptions explain selection. Input schemas explain arguments. This guide
2981
3284
 
2982
3285
  - Use selection with action=get when the user's Studio selection should define the scope.
2983
3286
  - Use action=set with instance paths to replace, add to, or remove from the selection. An empty paths array in set mode clears it.
2984
- - Use action=view with a BasePart or Model path to frame it. The current viewing direction is preserved unless from or angleY overrides it. padding below 1 crops closer and above 1 pulls back.
3287
+ - Use action=view with a BasePart or Model path to frame it, or omit path to frame exactly one selected BasePart or Model. Empty, multiple, or unsupported selections require an explicit target or a changed selection. The original camera type is restored after framing, including on failure. The current viewing direction is preserved unless from or angleY overrides it. padding below 1 crops closer and above 1 pulls back.
2985
3288
  - For visual proof, change the instance, frame it with selection, then call capture_screenshot.
2986
3289
 
2987
3290
  ## Script changes
@@ -4471,6 +4774,21 @@ async function resolveStudioExeAsync() {
4471
4774
  }
4472
4775
  return candidates[0];
4473
4776
  }
4777
+ var WINDOWS_STUDIO_PROCESS_QUERY = [
4778
+ "$ErrorActionPreference = 'Stop'",
4779
+ // Get-Process reports a missing name as an error, not a successful empty set.
4780
+ '$studio = @(); try { $studio = @(Get-Process RobloxStudioBeta -ErrorAction Stop) } catch { if ($_.FullyQualifiedErrorId -notlike "NoProcessFoundForGivenName,*") { throw } }',
4781
+ "$processes = @($studio | ForEach-Object { [PSCustomObject]@{ Id = $_.Id; Name = $_.Name; Path = $_.Path; MainWindowTitle = $_.MainWindowTitle; StartTimeUtcFileTime = $_.StartTime.ToUniversalTime().ToFileTimeUtc().ToString() } })",
4782
+ "ConvertTo-Json -InputObject $processes -Compress"
4783
+ ].join("; ");
4784
+ function parseWindowsStudioProcesses(output) {
4785
+ const parsed = JSON.parse(output);
4786
+ const processes = Array.isArray(parsed) ? parsed : [parsed];
4787
+ if (!processes.every((value) => value !== null && typeof value === "object" && "Id" in value && typeof value.Id === "number" && Number.isSafeInteger(value.Id) && value.Id > 0 && "Name" in value && typeof value.Name === "string" && "Path" in value && typeof value.Path === "string" && "MainWindowTitle" in value && typeof value.MainWindowTitle === "string" && "StartTimeUtcFileTime" in value && typeof value.StartTimeUtcFileTime === "string" && /^[1-9]\d*$/u.test(value.StartTimeUtcFileTime))) {
4788
+ throw new Error("Malformed Roblox Studio process enumeration result.");
4789
+ }
4790
+ return processes;
4791
+ }
4474
4792
  async function observeStudioProcesses() {
4475
4793
  const observedAt = Date.now();
4476
4794
  try {
@@ -4493,11 +4811,8 @@ async function observeStudioProcesses() {
4493
4811
  if (process.platform !== "win32" && !isWsl()) {
4494
4812
  return { status: "ok", observedAt, processes: [] };
4495
4813
  }
4496
- const out = await powershellAsync("Get-Process RobloxStudioBeta -ErrorAction SilentlyContinue | ForEach-Object { [PSCustomObject]@{ Id = $_.Id; Name = $_.Name; Path = $_.Path; MainWindowTitle = $_.MainWindowTitle; StartTimeUtcFileTime = $_.StartTime.ToUniversalTime().ToFileTimeUtc().ToString() } } | ConvertTo-Json -Compress");
4497
- if (!out)
4498
- return { status: "ok", observedAt, processes: [] };
4499
- const parsed = JSON.parse(out);
4500
- return { status: "ok", observedAt, processes: Array.isArray(parsed) ? parsed : [parsed] };
4814
+ const out = await powershellAsync(WINDOWS_STUDIO_PROCESS_QUERY);
4815
+ return { status: "ok", observedAt, processes: parseWindowsStudioProcesses(out) };
4501
4816
  } catch (error) {
4502
4817
  return {
4503
4818
  status: "error",
@@ -5529,7 +5844,6 @@ var INTERNAL_RESULT_KEYS = /* @__PURE__ */ new Set([
5529
5844
  "transportPeerId",
5530
5845
  "pluginVariant",
5531
5846
  "pluginVersion",
5532
- "requestId",
5533
5847
  "serverVersion"
5534
5848
  ]);
5535
5849
  var TEXT_RESULT_TOOLS = /* @__PURE__ */ new Set(["get_roblox_docs"]);
@@ -5651,8 +5965,11 @@ function publicToolErrorBody(name, error) {
5651
5965
  }
5652
5966
  if (error instanceof RoutingFailure)
5653
5967
  return publicRoutingError(error);
5968
+ if (error instanceof RequestFailure) {
5969
+ return { error: error.code, message: error.message.slice(0, 500), ...error.details };
5970
+ }
5654
5971
  console.error(`[tool:${name}]`, error);
5655
- const message = error instanceof Error ? error.message : "Tool execution failed.";
5972
+ const message = error instanceof Error ? error.message : typeof error === "string" ? error : "Tool execution failed.";
5656
5973
  return { error: "tool_failed", message: message.slice(0, 500) };
5657
5974
  }
5658
5975
  function concise(text, maxLength, firstSentence = false) {
@@ -5709,6 +6026,9 @@ function serverInstructions(definitions) {
5709
6026
  if (has("get_connected_instances")) {
5710
6027
  instructions.push("When more than one Studio process scope is connected, call get_connected_instances and pass either a top-level instance id or a multiplayer group's role-suffixed instance id as instance_id.");
5711
6028
  }
6029
+ if (has("get_request_status", "execute_luau", "set_properties")) {
6030
+ instructions.push("Supply a unique operation_id to execute_luau or set_properties when retry safety matters. After a timeout, query get_request_status with that ID before retrying. Identical arguments reuse a retained outcome; changed arguments are rejected. Recovery and deduplication are bounded to the current server session and five-minute retention window; result payloads may be evicted earlier. Unknown status does not mean unexecuted, and cancellation cannot roll back mutations.", "Request stages are queued, dispatched, executing (plugin handler entered), and response_delivery (handler returned or admission rejected). executionOutcome is separate from waiter state and delivery outcome; handler observations do not prove user Luau instructions ran. A waiter timeout is not an execution deadline or rollback. Neither missing progress nor connection loss proves completion.");
6031
+ }
5712
6032
  if (has("search_objects", "get_project_structure", "grep_scripts", "execute_luau")) {
5713
6033
  instructions.push("Use search_objects, get_project_structure, or grep_scripts for standard discovery. Use execute_luau for custom traversal or bulk edits.");
5714
6034
  }
@@ -5771,7 +6091,7 @@ function createToolHttpHandler(options) {
5771
6091
  }
5772
6092
 
5773
6093
  // ../core/dist/auth.js
5774
- import { randomBytes, createHash, timingSafeEqual } from "crypto";
6094
+ import { randomBytes, createHash as createHash2, timingSafeEqual } from "crypto";
5775
6095
  import { existsSync as existsSync3, mkdirSync as mkdirSync2, readFileSync as readFileSync3, writeFileSync, chmodSync } from "fs";
5776
6096
  import { join as join3, dirname as dirname2 } from "path";
5777
6097
  import { homedir as homedir3 } from "os";
@@ -5809,192 +6129,308 @@ function resolveAuthToken() {
5809
6129
  }
5810
6130
  }
5811
6131
  function tokensMatch(provided, expected) {
5812
- const a = createHash("sha256").update(provided).digest();
5813
- const b = createHash("sha256").update(expected).digest();
6132
+ const a = createHash2("sha256").update(provided).digest();
6133
+ const b = createHash2("sha256").update(expected).digest();
5814
6134
  return timingSafeEqual(a, b);
5815
6135
  }
5816
6136
 
6137
+ // ../core/dist/http-body-limits.js
6138
+ var HTTP_BODY_LIMIT_BYTES = 50 * 1024 * 1024;
6139
+
5817
6140
  // ../core/dist/studio-transport.js
5818
6141
  var HEARTBEAT_INTERVAL_MS = 1e4;
5819
- var MAX_ACTIVE_EVENT_STREAMS = 64;
5820
- var SseStudioTransport = class {
6142
+ var MAX_PENDING_ACKS = 128;
6143
+ var STUDIO_PROTOCOL_VERSION = 1;
6144
+ var MAX_ACTIVE_STUDIO_SOCKETS = 64;
6145
+ var MAX_STUDIO_FRAME_BYTES = 64 * 1024 * 1024;
6146
+ var MAX_STUDIO_BUFFERED_BYTES = MAX_STUDIO_FRAME_BYTES + 14;
6147
+ var WebSocketStudioTransport = class {
5821
6148
  queue;
5822
- streams = /* @__PURE__ */ new Map();
6149
+ sockets = /* @__PURE__ */ new Map();
6150
+ closing = /* @__PURE__ */ new Set();
5823
6151
  unsubscribeRequestAvailable;
5824
6152
  unsubscribePeerClosed;
5825
6153
  nextGeneration = 0;
6154
+ closed = false;
5826
6155
  constructor(queue) {
5827
6156
  this.queue = queue;
5828
6157
  this.unsubscribeRequestAvailable = queue.onRequestAvailable((transportPeerId) => {
5829
- const stream = this.streams.get(transportPeerId);
5830
- if (stream)
5831
- this.pump(stream);
6158
+ const connection = this.sockets.get(transportPeerId);
6159
+ if (connection)
6160
+ this.pump(connection);
5832
6161
  });
5833
- this.unsubscribePeerClosed = queue.onPeerClosed((route) => {
5834
- if (route.peerId === route.transportPeerId) {
5835
- this.closeTransport(route.transportPeerId);
5836
- }
6162
+ this.unsubscribePeerClosed = queue.onPeerClosed((peer) => {
6163
+ if (peer.peerId === peer.transportPeerId)
6164
+ this.closeTransport(peer.transportPeerId);
5837
6165
  });
5838
6166
  }
5839
- get activeStreamCount() {
5840
- return this.streams.size;
6167
+ get activeSocketCount() {
6168
+ return this.sockets.size;
5841
6169
  }
5842
6170
  canOpen(transportPeerId) {
5843
- return this.streams.has(transportPeerId) || this.streams.size < MAX_ACTIVE_EVENT_STREAMS;
6171
+ return !this.closed && (this.sockets.has(transportPeerId) || this.sockets.size < MAX_ACTIVE_STUDIO_SOCKETS);
5844
6172
  }
5845
- open(transportPeerId, sink, status) {
5846
- if (!this.canOpen(transportPeerId))
6173
+ open(transportPeerId, socket, status) {
6174
+ if (!this.canOpen(transportPeerId) || socket.readyState !== 1)
5847
6175
  return void 0;
5848
6176
  this.nextGeneration += 1;
5849
- const claimOwner = `sse:${transportPeerId}:${this.nextGeneration}`;
5850
- const stream = {
6177
+ const claimOwner = `ws:${transportPeerId}:${this.nextGeneration}`;
6178
+ const connection = {
5851
6179
  transportPeerId,
5852
6180
  claimOwner,
5853
- sink,
6181
+ socket,
5854
6182
  status,
5855
6183
  closed: false,
5856
- blocked: false,
6184
+ sending: false,
6185
+ pumping: false,
6186
+ settling: false,
5857
6187
  statusPending: true,
5858
- onClose: () => this.closeStream(stream),
5859
- onDrain: () => {
5860
- if (stream.closed)
5861
- return;
5862
- stream.blocked = false;
5863
- this.pump(stream);
5864
- }
6188
+ heartbeatPending: false,
6189
+ acknowledgements: /* @__PURE__ */ new Map(),
6190
+ onClose: () => {
6191
+ this.closeSocket(connection);
6192
+ this.finishClose(connection);
6193
+ },
6194
+ onError: () => this.closeSocket(connection, 1011, "socket_error"),
6195
+ onMessage: (data, isBinary) => this.receive(connection, data, isBinary)
5865
6196
  };
5866
6197
  this.queue.setDeliveryActive(transportPeerId, claimOwner, true);
5867
- this.queue.updatePeerActivity(transportPeerId);
5868
- const replaced = this.streams.get(transportPeerId);
6198
+ const replaced = this.sockets.get(transportPeerId);
5869
6199
  if (replaced)
5870
- this.closeStream(replaced, true);
5871
- this.streams.set(transportPeerId, stream);
5872
- sink.on("close", stream.onClose);
5873
- sink.on("error", stream.onClose);
5874
- sink.on("drain", stream.onDrain);
5875
- stream.heartbeatTimer = setInterval(() => {
5876
- if (!stream.closed && !stream.blocked) {
5877
- this.queue.updatePeerActivity(transportPeerId);
5878
- stream.statusPending = true;
5879
- this.pump(stream);
5880
- if (!stream.blocked) {
5881
- this.write(stream, { kind: "heartbeat", timestamp: Date.now() });
5882
- }
5883
- }
6200
+ this.closeSocket(replaced, 1012, "transport_replaced");
6201
+ this.sockets.set(transportPeerId, connection);
6202
+ this.queue.updatePeerActivity(transportPeerId);
6203
+ socket.on("close", connection.onClose);
6204
+ socket.on("error", connection.onError);
6205
+ socket.on("message", connection.onMessage);
6206
+ connection.heartbeatTimer = setInterval(() => {
6207
+ if (!this.isCurrent(connection))
6208
+ return;
6209
+ this.queue.updatePeerActivity(transportPeerId);
6210
+ connection.statusPending = true;
6211
+ connection.heartbeatPending = true;
6212
+ this.pump(connection);
5884
6213
  }, HEARTBEAT_INTERVAL_MS);
5885
- stream.heartbeatTimer.unref();
5886
- this.pump(stream);
5887
- return {
5888
- transportPeerId,
5889
- close: () => this.closeStream(stream, true)
5890
- };
6214
+ connection.heartbeatTimer.unref();
6215
+ this.pump(connection);
6216
+ return { transportPeerId, close: () => this.closeSocket(connection, 1e3, "transport_closed") };
5891
6217
  }
5892
6218
  refreshStatus(transportPeerId) {
5893
6219
  if (transportPeerId !== void 0) {
5894
- const stream = this.streams.get(transportPeerId);
5895
- if (stream) {
5896
- stream.lastStatusJson = void 0;
5897
- stream.statusPending = true;
5898
- this.pump(stream);
6220
+ const connection = this.sockets.get(transportPeerId);
6221
+ if (connection) {
6222
+ connection.statusPending = true;
6223
+ this.pump(connection);
5899
6224
  }
5900
6225
  return;
5901
6226
  }
5902
- for (const stream of this.streams.values()) {
5903
- stream.lastStatusJson = void 0;
5904
- stream.statusPending = true;
5905
- this.pump(stream);
6227
+ for (const connection of this.sockets.values()) {
6228
+ connection.statusPending = true;
6229
+ this.pump(connection);
5906
6230
  }
5907
6231
  }
5908
6232
  closeTransport(transportPeerId) {
5909
- const stream = this.streams.get(transportPeerId);
5910
- if (stream)
5911
- this.closeStream(stream, true);
6233
+ const connection = this.sockets.get(transportPeerId);
6234
+ if (connection)
6235
+ this.closeSocket(connection, 1e3, "peer_unregistered");
5912
6236
  }
5913
6237
  close() {
5914
- for (const stream of Array.from(this.streams.values())) {
5915
- this.closeStream(stream, true);
5916
- }
6238
+ this.closed = true;
6239
+ for (const connection of this.sockets.values())
6240
+ this.closeSocket(connection, 1001, "server_shutdown");
5917
6241
  this.unsubscribeRequestAvailable();
5918
6242
  this.unsubscribePeerClosed();
5919
6243
  }
5920
- pump(stream) {
5921
- if (stream.closed || stream.blocked || this.streams.get(stream.transportPeerId) !== stream)
6244
+ isCurrent(connection) {
6245
+ return !connection.closed && this.sockets.get(connection.transportPeerId) === connection;
6246
+ }
6247
+ receive(connection, data, isBinary) {
6248
+ if (!this.isCurrent(connection))
6249
+ return;
6250
+ if (isBinary) {
6251
+ this.closeSocket(connection, 1003, "text_frames_required");
5922
6252
  return;
5923
- if (stream.statusPending) {
5924
- stream.statusPending = false;
5925
- let status;
5926
- try {
5927
- status = stream.status();
5928
- } catch {
5929
- this.closeStream(stream);
5930
- return;
5931
- }
5932
- const statusJson = JSON.stringify(status);
5933
- if (statusJson !== stream.lastStatusJson) {
5934
- stream.lastStatusJson = statusJson;
5935
- if (!this.write(stream, status))
5936
- return;
5937
- }
5938
6253
  }
5939
- while (!stream.closed && !stream.blocked) {
5940
- const cancellation = this.queue.claimNextCancellationForTransport(stream.transportPeerId, stream.claimOwner);
5941
- if (!cancellation)
5942
- break;
5943
- if (!this.write(stream, { kind: "cancel", ...cancellation }))
6254
+ const bytes = Array.isArray(data) ? data.reduce((total, chunk) => total + chunk.byteLength, 0) : data.byteLength;
6255
+ if (bytes > MAX_STUDIO_FRAME_BYTES) {
6256
+ this.closeSocket(connection, 1009, `server_receive bytes=${bytes} limit=${MAX_STUDIO_FRAME_BYTES}`);
6257
+ return;
6258
+ }
6259
+ let message;
6260
+ try {
6261
+ const buffer = Array.isArray(data) ? Buffer.concat(data, bytes) : data instanceof ArrayBuffer ? Buffer.from(data) : data;
6262
+ message = JSON.parse(buffer.toString("utf8"));
6263
+ } catch {
6264
+ this.closeSocket(connection, 1007, "invalid_json");
6265
+ return;
6266
+ }
6267
+ if (message === null || typeof message !== "object" || Array.isArray(message) || !("kind" in message) || message.kind !== "response" && message.kind !== "progress" || !("requestId" in message) || typeof message.requestId !== "string" || message.requestId.length === 0 || message.requestId.length > 1024) {
6268
+ this.closeSocket(connection, 1008, "invalid_response");
6269
+ return;
6270
+ }
6271
+ if (message.kind === "progress") {
6272
+ const outcome = "outcome" in message ? message.outcome : void 0;
6273
+ if (!("phase" in message) || message.phase !== "executing" && message.phase !== "response_delivery" || outcome !== void 0 && !isExecutionOutcome(outcome) || message.phase === "executing" && "outcome" in message)
5944
6274
  return;
6275
+ this.queue.observeTransportProgress(connection.transportPeerId, message.requestId, message.phase, outcome);
6276
+ this.queue.updatePeerActivity(connection.transportPeerId);
6277
+ return;
5945
6278
  }
5946
- while (!stream.closed && !stream.blocked) {
5947
- const request = this.queue.claimNextRequestForTransport(stream.transportPeerId, stream.claimOwner);
5948
- if (!request)
6279
+ const executionOutcome = "executionOutcome" in message ? message.executionOutcome : void 0;
6280
+ if (executionOutcome !== void 0 && !isExecutionOutcome(executionOutcome))
6281
+ return;
6282
+ const response = "response" in message ? message.response : void 0;
6283
+ const error = "error" in message ? message.error : void 0;
6284
+ connection.settling = true;
6285
+ try {
6286
+ const disposition = this.queue.settleTransportResponse(connection.transportPeerId, message.requestId, response, error, executionOutcome);
6287
+ if (!this.isCurrent(connection))
5949
6288
  return;
5950
- const event = {
5951
- kind: "request",
5952
- requestId: request.requestId,
5953
- peerId: request.peerId,
5954
- target: request.target,
5955
- endpoint: request.endpoint,
5956
- data: request.data === void 0 ? null : request.data,
5957
- remainingMs: request.remainingMs
5958
- };
5959
- if (!this.write(stream, event))
6289
+ if (!connection.acknowledgements.has(message.requestId) && connection.acknowledgements.size >= MAX_PENDING_ACKS) {
6290
+ this.closeSocket(connection, 1013, "ack_backpressure");
5960
6291
  return;
6292
+ }
6293
+ connection.acknowledgements.set(message.requestId, {
6294
+ kind: "ack",
6295
+ requestId: message.requestId,
6296
+ disposition
6297
+ });
6298
+ this.queue.updatePeerActivity(connection.transportPeerId);
6299
+ } catch {
6300
+ this.closeSocket(connection, 1011, "response_recording_failed");
6301
+ } finally {
6302
+ connection.settling = false;
5961
6303
  }
6304
+ this.pump(connection);
5962
6305
  }
5963
- write(stream, event) {
5964
- if (stream.closed)
5965
- return false;
6306
+ pump(connection) {
6307
+ if (!this.isCurrent(connection) || connection.sending || connection.pumping || connection.settling)
6308
+ return;
6309
+ connection.pumping = true;
5966
6310
  try {
5967
- const writable = stream.sink.write(`data: ${JSON.stringify(event)}
5968
-
5969
- `);
5970
- if (!writable)
5971
- stream.blocked = true;
5972
- return writable;
6311
+ while (this.isCurrent(connection) && !connection.sending) {
6312
+ const ack = connection.acknowledgements.values().next().value;
6313
+ if (ack) {
6314
+ connection.acknowledgements.delete(ack.requestId);
6315
+ this.send(connection, ack);
6316
+ continue;
6317
+ }
6318
+ if (connection.statusPending) {
6319
+ connection.statusPending = false;
6320
+ const status = connection.status();
6321
+ const statusJson = JSON.stringify(status);
6322
+ if (statusJson !== connection.lastStatusJson) {
6323
+ connection.lastStatusJson = statusJson;
6324
+ this.send(connection, status, statusJson);
6325
+ continue;
6326
+ }
6327
+ }
6328
+ const cancellation = this.queue.claimNextCancellationForTransport(connection.transportPeerId, connection.claimOwner);
6329
+ if (cancellation) {
6330
+ this.send(connection, { kind: "cancel", ...cancellation });
6331
+ continue;
6332
+ }
6333
+ const request = this.queue.claimNextRequestForTransport(connection.transportPeerId, connection.claimOwner);
6334
+ if (request) {
6335
+ this.send(connection, { kind: "request", ...request, data: request.data ?? null });
6336
+ continue;
6337
+ }
6338
+ if (connection.heartbeatPending) {
6339
+ connection.heartbeatPending = false;
6340
+ this.send(connection, { kind: "heartbeat", timestamp: Date.now() });
6341
+ continue;
6342
+ }
6343
+ return;
6344
+ }
5973
6345
  } catch {
5974
- this.closeStream(stream);
5975
- return false;
6346
+ this.closeSocket(connection, 1011, "transport_pump_failed");
6347
+ } finally {
6348
+ connection.pumping = false;
5976
6349
  }
5977
6350
  }
5978
- closeStream(stream, endSink = false) {
5979
- if (stream.closed)
6351
+ send(connection, event, serialized) {
6352
+ let json;
6353
+ try {
6354
+ json = serialized ?? JSON.stringify(event);
6355
+ } catch {
6356
+ if (event.kind !== "request")
6357
+ throw new Error("Unserializable Studio event");
6358
+ this.queue.settleTransportResponse(connection.transportPeerId, event.requestId, void 0, new RequestFailure("Studio request could not be serialized before WebSocket delivery at server_send", "studio_frame_serialization_failed", {
6359
+ requestId: event.requestId,
6360
+ targetPeerId: event.peerId,
6361
+ stage: "dispatched",
6362
+ outcome: "not_executed",
6363
+ executionOutcome: "not_executed",
6364
+ transportStage: "server_send"
6365
+ }));
5980
6366
  return;
5981
- stream.closed = true;
5982
- clearInterval(stream.heartbeatTimer);
5983
- stream.sink.removeListener("close", stream.onClose);
5984
- stream.sink.removeListener("error", stream.onClose);
5985
- stream.sink.removeListener("drain", stream.onDrain);
5986
- if (this.streams.get(stream.transportPeerId) === stream) {
5987
- this.streams.delete(stream.transportPeerId);
5988
- }
5989
- this.queue.updatePeerActivity(stream.transportPeerId);
5990
- this.queue.setDeliveryActive(stream.transportPeerId, stream.claimOwner, false);
5991
- this.queue.releaseDeliveryClaims(stream.claimOwner);
5992
- if (endSink) {
5993
- try {
5994
- stream.sink.end();
5995
- } catch {
6367
+ }
6368
+ const bytes = Buffer.byteLength(json);
6369
+ if (bytes > MAX_STUDIO_FRAME_BYTES) {
6370
+ if (event.kind === "request") {
6371
+ this.queue.settleTransportResponse(connection.transportPeerId, event.requestId, void 0, new RequestFailure(`Studio request frame is ${bytes} bytes; limit is ${MAX_STUDIO_FRAME_BYTES} at server_send`, "studio_frame_too_large", {
6372
+ requestId: event.requestId,
6373
+ targetPeerId: event.peerId,
6374
+ stage: "dispatched",
6375
+ outcome: "not_executed",
6376
+ executionOutcome: "not_executed",
6377
+ transportStage: "server_send",
6378
+ bytes,
6379
+ limitBytes: MAX_STUDIO_FRAME_BYTES
6380
+ }));
6381
+ } else {
6382
+ this.closeSocket(connection, 1009, `server_send bytes=${bytes} limit=${MAX_STUDIO_FRAME_BYTES}`);
5996
6383
  }
6384
+ return;
5997
6385
  }
6386
+ if (connection.socket.readyState !== 1 || connection.socket.bufferedAmount + bytes > MAX_STUDIO_BUFFERED_BYTES) {
6387
+ this.closeSocket(connection, 1013, "server_send_backpressure");
6388
+ return;
6389
+ }
6390
+ connection.sending = true;
6391
+ connection.socket.send(json, (error) => {
6392
+ if (!this.isCurrent(connection))
6393
+ return;
6394
+ connection.sending = false;
6395
+ if (error)
6396
+ this.closeSocket(connection, 1011, "server_send_failed");
6397
+ else
6398
+ this.pump(connection);
6399
+ });
6400
+ }
6401
+ closeSocket(connection, code, reason) {
6402
+ if (connection.closed)
6403
+ return;
6404
+ connection.closed = true;
6405
+ clearInterval(connection.heartbeatTimer);
6406
+ connection.socket.removeListener("message", connection.onMessage);
6407
+ connection.acknowledgements.clear();
6408
+ if (this.sockets.get(connection.transportPeerId) === connection)
6409
+ this.sockets.delete(connection.transportPeerId);
6410
+ this.queue.setDeliveryActive(connection.transportPeerId, connection.claimOwner, false);
6411
+ this.queue.releaseDeliveryClaims(connection.claimOwner);
6412
+ if (code !== void 0) {
6413
+ this.closing.add(connection);
6414
+ connection.closeTimer = setTimeout(() => {
6415
+ connection.socket.terminate();
6416
+ this.finishClose(connection);
6417
+ }, 1e3);
6418
+ connection.closeTimer.unref();
6419
+ connection.socket.close(code, reason);
6420
+ if (this.closing.size > MAX_ACTIVE_STUDIO_SOCKETS) {
6421
+ const oldest = this.closing.values().next().value;
6422
+ if (oldest) {
6423
+ oldest.socket.terminate();
6424
+ this.finishClose(oldest);
6425
+ }
6426
+ }
6427
+ }
6428
+ }
6429
+ finishClose(connection) {
6430
+ clearTimeout(connection.closeTimer);
6431
+ this.closing.delete(connection);
6432
+ connection.socket.removeListener("close", connection.onClose);
6433
+ connection.socket.removeListener("error", connection.onError);
5998
6434
  }
5999
6435
  };
6000
6436
 
@@ -6074,13 +6510,14 @@ function requiredClosedLineRange(body, toolName) {
6074
6510
  return { startLine: parsed.startLine, endLine: parsed.endLine };
6075
6511
  }
6076
6512
  var TOOL_HANDLERS = {
6513
+ get_request_status: (tools, body) => tools.getRequestStatus(body.request_id),
6077
6514
  get_roblox_skills: (tools, body) => tools.getRobloxSkills(body.action, body.name),
6078
6515
  get_roblox_docs: (tools, body) => tools.getRobloxDocs(body.name, body.doc_type, body.section),
6079
6516
  get_place_info: (tools, body) => tools.getPlaceInfo(body.instance_id),
6080
6517
  search_objects: (tools, body) => tools.searchObjects(body.query, body.searchType, body.propertyName, body.instance_id),
6081
6518
  get_instance_properties: (tools, body) => tools.getInstanceProperties(body.instancePath, body.excludeSource, body.instance_id),
6082
6519
  get_project_structure: (tools, body) => tools.getProjectStructure(body.path, body.maxDepth, body.scriptsOnly, body.instance_id),
6083
- set_properties: (tools, body) => tools.setProperties(body.instancePath, body.properties, body.instance_id),
6520
+ set_properties: (tools, body) => tools.setProperties(body.instancePath, body.properties, body.instance_id, body.operation_id),
6084
6521
  grep_scripts: (tools, body, context) => tools.grepScripts(body.pattern, {
6085
6522
  caseSensitive: body.caseSensitive,
6086
6523
  usePattern: body.usePattern,
@@ -6104,7 +6541,7 @@ var TOOL_HANDLERS = {
6104
6541
  },
6105
6542
  get_attributes: (tools, body) => tools.getAttributes(body.instancePath, body.instance_id),
6106
6543
  selection: (tools, body) => tools.selection(body.action, body, body.instance_id),
6107
- execute_luau: (tools, body) => tools.executeLuau(body.code, body.target, body.instance_id),
6544
+ execute_luau: (tools, body) => tools.executeLuau(body.code, body.target, body.instance_id, body.operation_id),
6108
6545
  eval_server_runtime: (tools, body) => tools.evalServerRuntime(body.code, body.instance_id),
6109
6546
  eval_client_runtime: (tools, body) => tools.evalClientRuntime(body.code, body.target, body.instance_id),
6110
6547
  set_network_profile: (tools, body) => tools.setNetworkProfile(body.profile, body.target, body.overrides, body.instance_id),
@@ -6116,7 +6553,7 @@ var TOOL_HANDLERS = {
6116
6553
  manage_instance: (tools, body) => tools.manageInstance(body),
6117
6554
  solo_playtest: (tools, body) => tools.soloPlaytest(body.action, body.mode, body.timeout, body.instance_id),
6118
6555
  multiplayer_playtest: (tools, body) => tools.multiplayerPlaytest(body.action, body.numPlayers, body.target, body.testArgs, body.value, body.timeout, body.instance_id),
6119
- get_runtime_logs: (tools, body) => tools.getRuntimeLogs(body.instance_id, body.multiplayer_group_id, body.cursor, body.cursor_by_instance, body.tail, body.filter),
6556
+ get_runtime_logs: (tools, body, context) => tools.getRuntimeLogs(body.instance_id, body.multiplayer_group_id, body.cursor, body.cursor_by_instance, body.tail, body.filter, context?.signal),
6120
6557
  capture_script_profiler: (tools, body) => tools.captureScriptProfiler(body.target, {
6121
6558
  duration_ms: body.duration_ms,
6122
6559
  frequency: body.frequency,
@@ -6172,6 +6609,16 @@ var TOOL_HANDLERS = {
6172
6609
  maxReplacements: body.maxReplacements
6173
6610
  }, body.instance_id)
6174
6611
  };
6612
+ var MAX_STUDIO_SESSIONS = 256;
6613
+ function rejectStudioUpgrade(socket, status, error) {
6614
+ const body = JSON.stringify({ error });
6615
+ socket.end(`HTTP/1.1 ${status} ${http.STATUS_CODES[status]}\r
6616
+ Connection: close\r
6617
+ Content-Type: application/json\r
6618
+ Content-Length: ${Buffer.byteLength(body)}\r
6619
+ \r
6620
+ ${body}`);
6621
+ }
6175
6622
  function createHttpServer(tools, bridge, allowedTools, serverConfig, security) {
6176
6623
  const app = express();
6177
6624
  const studioLifecycleCallable = !allowedTools || allowedTools.has("manage_instance");
@@ -6181,8 +6628,19 @@ function createHttpServer(tools, bridge, allowedTools, serverConfig, security) {
6181
6628
  let mcpServerStartTime = 0;
6182
6629
  const proxyInstances = /* @__PURE__ */ new Set();
6183
6630
  const rejectedVersionPeers = /* @__PURE__ */ new Set();
6184
- const eventTransport = new SseStudioTransport(bridge);
6185
- const eventStreamHandles = /* @__PURE__ */ new Set();
6631
+ const studioTransport = new WebSocketStudioTransport(bridge);
6632
+ const transportTokens = /* @__PURE__ */ new Map();
6633
+ const boundServers = /* @__PURE__ */ new Set();
6634
+ const webSocketServer = new WebSocketServer({
6635
+ noServer: true,
6636
+ clientTracking: false,
6637
+ perMessageDeflate: false,
6638
+ maxPayload: MAX_STUDIO_FRAME_BYTES
6639
+ });
6640
+ let closed = false;
6641
+ const unsubscribePeerClosed = bridge.onPeerClosed((peer) => {
6642
+ transportTokens.delete(peer.peerId);
6643
+ });
6186
6644
  const setMCPServerActive = (active) => {
6187
6645
  mcpServerActive = active;
6188
6646
  if (active) {
@@ -6192,14 +6650,14 @@ function createHttpServer(tools, bridge, allowedTools, serverConfig, security) {
6192
6650
  mcpServerStartTime = 0;
6193
6651
  lastMCPActivity = 0;
6194
6652
  }
6195
- eventTransport.refreshStatus();
6653
+ studioTransport.refreshStatus();
6196
6654
  };
6197
6655
  const trackMCPActivity = () => {
6198
6656
  if (mcpServerActive) {
6199
6657
  const wasConnected = Date.now() - lastMCPActivity < 3e4;
6200
6658
  lastMCPActivity = Date.now();
6201
6659
  if (!wasConnected)
6202
- eventTransport.refreshStatus();
6660
+ studioTransport.refreshStatus();
6203
6661
  }
6204
6662
  };
6205
6663
  const isMCPServerActive = () => {
@@ -6223,6 +6681,67 @@ function createHttpServer(tools, bridge, allowedTools, serverConfig, security) {
6223
6681
  return bridge.getPeers().length > 0;
6224
6682
  };
6225
6683
  const allowedOrigins = new Set(security?.allowedOrigins ?? []);
6684
+ const upgradeStudio = (req, socket, head) => {
6685
+ socket.on("error", () => socket.destroy());
6686
+ if (closed) {
6687
+ rejectStudioUpgrade(socket, 503, "server_shutdown");
6688
+ return;
6689
+ }
6690
+ let url;
6691
+ try {
6692
+ url = new URL(req.url ?? "/", "http://localhost");
6693
+ } catch {
6694
+ rejectStudioUpgrade(socket, 400, "invalid_websocket_url");
6695
+ return;
6696
+ }
6697
+ if (url.pathname !== "/studio") {
6698
+ rejectStudioUpgrade(socket, 404, "unknown_websocket_endpoint");
6699
+ return;
6700
+ }
6701
+ const origin = req.headers.origin;
6702
+ if (origin && !allowedOrigins.has(origin)) {
6703
+ rejectStudioUpgrade(socket, 403, "forbidden_origin");
6704
+ return;
6705
+ }
6706
+ if (req.method !== "GET" || url.searchParams.getAll("protocolVersion").length !== 1 || url.searchParams.get("protocolVersion") !== String(STUDIO_PROTOCOL_VERSION)) {
6707
+ rejectStudioUpgrade(socket, 426, "studio_protocol_mismatch");
6708
+ return;
6709
+ }
6710
+ const peerId = url.searchParams.get("peerId");
6711
+ if (!peerId || url.searchParams.getAll("peerId").length !== 1) {
6712
+ rejectStudioUpgrade(socket, 400, "missing_peer_id");
6713
+ return;
6714
+ }
6715
+ const peer = bridge.getPeerById(peerId);
6716
+ if (!peer) {
6717
+ rejectStudioUpgrade(socket, 404, "unknown_peer");
6718
+ return;
6719
+ }
6720
+ if (peer.transportPeerId !== peerId) {
6721
+ rejectStudioUpgrade(socket, 403, "peer_has_no_socket");
6722
+ return;
6723
+ }
6724
+ const token = transportTokens.get(peerId);
6725
+ const provided = req.headers["x-studio-token"];
6726
+ if (!token || typeof provided !== "string" || !tokensMatch(provided, token)) {
6727
+ rejectStudioUpgrade(socket, 401, "invalid_studio_token");
6728
+ return;
6729
+ }
6730
+ if (!studioTransport.canOpen(peerId)) {
6731
+ rejectStudioUpgrade(socket, 503, "studio_socket_capacity_reached");
6732
+ return;
6733
+ }
6734
+ webSocketServer.handleUpgrade(req, socket, head, (webSocket) => {
6735
+ webSocket.on("error", (error) => {
6736
+ if ("code" in error && error.code === "WS_ERR_UNSUPPORTED_MESSAGE_LENGTH") {
6737
+ console.error(`[studio-websocket] server_receive frame exceeds limitBytes=${MAX_STUDIO_FRAME_BYTES}`);
6738
+ }
6739
+ });
6740
+ const handle = studioTransport.open(peerId, webSocket, () => eventStatus(peerId));
6741
+ if (!handle)
6742
+ webSocket.close(1013, "studio_socket_capacity_reached");
6743
+ });
6744
+ };
6226
6745
  app.use((req, res, next) => {
6227
6746
  const origin = req.headers.origin;
6228
6747
  if (typeof origin !== "string" || origin === "") {
@@ -6247,7 +6766,10 @@ function createHttpServer(tools, bridge, allowedTools, serverConfig, security) {
6247
6766
  next();
6248
6767
  });
6249
6768
  const authToken = security?.authToken;
6250
- const authRequired = (path6) => path6 === "/mcp" || path6.startsWith("/mcp/") || path6 === "/proxy" || path6 === "/topology" || path6 === "/unregister-instance-id" || path6 === "/create-multiplayer-group" || path6 === "/remove-multiplayer-group";
6769
+ const authRequired = (requestPath) => {
6770
+ const path6 = requestPath.toLowerCase().replace(/\/+$/, "");
6771
+ return path6 === "/mcp" || path6.startsWith("/mcp/") || path6 === "/proxy" || path6 === "/topology" || path6 === "/request-status" || path6 === "/unregister-instance-id" || path6 === "/create-multiplayer-group" || path6 === "/remove-multiplayer-group";
6772
+ };
6251
6773
  app.use((req, res, next) => {
6252
6774
  if (!authToken || !authRequired(req.path)) {
6253
6775
  next();
@@ -6265,8 +6787,31 @@ function createHttpServer(tools, bridge, allowedTools, serverConfig, security) {
6265
6787
  message: 'Missing or invalid auth token. Send it as "X-MCP-Auth: <token>" or "Authorization: Bearer <token>". ' + (security?.authTokenHint ?? "The token is in ~/.robloxstudio-mcp/auth-token (or ROBLOX_STUDIO_AUTH_TOKEN).")
6266
6788
  });
6267
6789
  });
6268
- app.use(express.json({ limit: "50mb" }));
6269
- app.use(express.urlencoded({ limit: "50mb", extended: true }));
6790
+ app.use(express.json({ limit: HTTP_BODY_LIMIT_BYTES }));
6791
+ app.use(express.urlencoded({ limit: HTTP_BODY_LIMIT_BYTES, extended: true }));
6792
+ const handleBodySizeError = (error, _req, res, next) => {
6793
+ if (!error || typeof error !== "object" || !("type" in error) || error.type !== "entity.too.large") {
6794
+ next(error);
6795
+ return;
6796
+ }
6797
+ const bytes = "received" in error && typeof error.received === "number" ? error.received : "length" in error && typeof error.length === "number" ? error.length : void 0;
6798
+ if (bytes === void 0) {
6799
+ next(error);
6800
+ return;
6801
+ }
6802
+ res.status(413).json({
6803
+ error: `HTTP request body is ${bytes} bytes at http_receive; limit ${HTTP_BODY_LIMIT_BYTES} bytes; queued; not_executed`,
6804
+ code: "request_too_large",
6805
+ details: {
6806
+ bytes,
6807
+ limitBytes: HTTP_BODY_LIMIT_BYTES,
6808
+ stage: "queued",
6809
+ outcome: "not_executed",
6810
+ transportStage: "http_receive"
6811
+ }
6812
+ });
6813
+ };
6814
+ app.use(handleBodySizeError);
6270
6815
  app.get("/health", (req, res) => {
6271
6816
  const peers = bridge.getPublicPeers().map(toPassivePeer);
6272
6817
  const instances = bridge.getPublicInstances().map(toPassiveInstance);
@@ -6296,7 +6841,8 @@ function createHttpServer(tools, bridge, allowedTools, serverConfig, security) {
6296
6841
  uptime: mcpServerActive ? Date.now() - mcpServerStartTime : 0,
6297
6842
  pendingRequests: bridge.getPendingRequestCount(),
6298
6843
  proxyInstanceCount: proxyInstances.size,
6299
- activeEventStreams: eventTransport.activeStreamCount,
6844
+ activeWebSockets: studioTransport.activeSocketCount,
6845
+ studioSocketCapacity: MAX_ACTIVE_STUDIO_SOCKETS,
6300
6846
  streamableHttp: !!serverConfig
6301
6847
  });
6302
6848
  });
@@ -6401,6 +6947,10 @@ function createHttpServer(tools, bridge, allowedTools, serverConfig, security) {
6401
6947
  return;
6402
6948
  }
6403
6949
  }
6950
+ if (closed || !isProxiedPeer && !transportTokens.has(peerId) && transportTokens.size >= MAX_STUDIO_SESSIONS) {
6951
+ res.status(503).json({ success: false, error: closed ? "server_shutdown" : "studio_session_capacity_reached" });
6952
+ return;
6953
+ }
6404
6954
  let result;
6405
6955
  try {
6406
6956
  result = bridge.registerPeer({
@@ -6437,14 +6987,20 @@ function createHttpServer(tools, bridge, allowedTools, serverConfig, security) {
6437
6987
  });
6438
6988
  return;
6439
6989
  }
6440
- eventTransport.refreshStatus(transportPeerId);
6990
+ let transportToken;
6991
+ if (!isProxiedPeer) {
6992
+ transportToken = transportTokens.get(peerId) ?? randomBytes2(32).toString("hex");
6993
+ transportTokens.set(peerId, transportToken);
6994
+ }
6995
+ studioTransport.refreshStatus(transportPeerId);
6441
6996
  res.json({
6442
6997
  success: true,
6443
6998
  assignedRole: result.assignedRole,
6444
6999
  peerId: result.peerId,
6445
7000
  instanceId: result.instanceId,
6446
7001
  multiplayerGroupId: result.multiplayerGroupId,
6447
- serverVersion
7002
+ serverVersion,
7003
+ ...!isProxiedPeer ? { protocolVersion: STUDIO_PROTOCOL_VERSION, transportToken } : {}
6448
7004
  });
6449
7005
  });
6450
7006
  app.post("/disconnect", (req, res) => {
@@ -6504,71 +7060,24 @@ function createHttpServer(tools, bridge, allowedTools, serverConfig, security) {
6504
7060
  serverVersion: serverConfig?.version
6505
7061
  });
6506
7062
  });
6507
- app.get("/events", (req, res) => {
6508
- const peerId = typeof req.query.peerId === "string" ? req.query.peerId : void 0;
6509
- if (!peerId) {
6510
- res.status(400).json({
6511
- error: "missing_peer_id",
6512
- message: "peerId is required"
6513
- });
6514
- return;
6515
- }
6516
- const peer = bridge.getPeerById(peerId);
6517
- if (!peer) {
6518
- res.status(404).json({
6519
- error: "unknown_peer",
6520
- knownPeer: false
6521
- });
6522
- return;
6523
- }
6524
- if (peer.transportPeerId !== peerId) {
6525
- res.status(409).json({
6526
- error: "peer_has_no_event_stream",
6527
- transportPeerId: peer.transportPeerId
6528
- });
6529
- return;
6530
- }
6531
- if (!eventTransport.canOpen(peerId)) {
6532
- res.setHeader("Retry-After", "1");
6533
- res.status(503).json({
6534
- error: "event_stream_capacity_reached",
6535
- capacity: MAX_ACTIVE_EVENT_STREAMS
6536
- });
6537
- return;
6538
- }
6539
- bridge.updatePeerActivity(peerId);
6540
- res.status(200);
6541
- res.setHeader("Content-Type", "text/event-stream; charset=utf-8");
6542
- res.setHeader("Cache-Control", "no-cache, no-transform");
6543
- res.setHeader("Connection", "keep-alive");
6544
- res.setHeader("X-Accel-Buffering", "no");
6545
- res.flushHeaders();
6546
- const handle = eventTransport.open(peerId, res, () => eventStatus(peerId));
6547
- if (!handle) {
6548
- res.end();
6549
- return;
6550
- }
6551
- eventStreamHandles.add(handle);
6552
- res.once("close", () => eventStreamHandles.delete(handle));
7063
+ app.get(["/studio", "/events"], (_req, res) => {
7064
+ res.setHeader("Upgrade", "websocket");
7065
+ res.status(426).json({ error: "studio_websocket_required", protocolVersion: STUDIO_PROTOCOL_VERSION });
6553
7066
  });
6554
- app.post("/response", (req, res) => {
6555
- const { requestId, response, error } = req.body;
6556
- if (typeof requestId !== "string" || requestId.length === 0) {
6557
- res.status(400).json({
6558
- success: false,
6559
- error: "invalid_request_id"
6560
- });
6561
- return;
6562
- }
6563
- const disposition = error !== void 0 ? bridge.rejectRequest(requestId, error) : bridge.resolveRequest(requestId, response);
6564
- if (disposition === "unknown") {
6565
- res.status(404).json({ success: false, disposition });
7067
+ app.post("/response", (_req, res) => {
7068
+ res.setHeader("Upgrade", "websocket");
7069
+ res.status(426).json({ error: "studio_websocket_required", protocolVersion: STUDIO_PROTOCOL_VERSION });
7070
+ });
7071
+ app.get("/request-status", (req, res) => {
7072
+ const requestId = req.query.requestId;
7073
+ if (typeof requestId !== "string" || requestId.length === 0 || requestId.length > 1024) {
7074
+ res.status(400).json({ error: "invalid_request_id" });
6566
7075
  return;
6567
7076
  }
6568
- res.json({ success: true, disposition });
7077
+ res.json({ status: bridge.getRequestStatus(requestId) ?? null });
6569
7078
  });
6570
7079
  app.post("/proxy", async (req, res) => {
6571
- const { endpoint, data, targetPeerId, proxyInstanceId, timeoutMs } = req.body;
7080
+ const { endpoint, data, targetPeerId, proxyInstanceId, timeoutMs, operationId } = req.body;
6572
7081
  if (!endpoint || !targetPeerId) {
6573
7082
  res.status(400).json({ error: "endpoint and targetPeerId are required" });
6574
7083
  return;
@@ -6580,17 +7089,22 @@ function createHttpServer(tools, bridge, allowedTools, serverConfig, security) {
6580
7089
  if (proxyInstanceId) {
6581
7090
  proxyInstances.add(proxyInstanceId);
6582
7091
  }
7092
+ if (operationId !== void 0 && (typeof operationId !== "string" || operationId.trim().length === 0 || operationId.length > 128)) {
7093
+ res.status(400).json({ error: "operationId must be a non-empty string of at most 128 characters" });
7094
+ return;
7095
+ }
6583
7096
  const controller = new AbortController();
6584
7097
  const abort = () => controller.abort();
6585
7098
  req.once("aborted", abort);
6586
7099
  res.once("close", abort);
6587
7100
  try {
6588
- const response = await bridge.sendRequest(endpoint, data, targetPeerId, timeoutMs, controller.signal);
7101
+ const response = await bridge.sendRequest(endpoint, data, targetPeerId, timeoutMs, controller.signal, operationId);
6589
7102
  res.json({ response });
6590
7103
  } catch (error) {
6591
7104
  if (!res.headersSent && !res.destroyed) {
6592
7105
  res.status(500).json({
6593
- error: error instanceof Error ? error.message : "Proxy request failed"
7106
+ error: error instanceof Error ? error.message : error === void 0 ? "Proxy request failed" : error,
7107
+ ...error instanceof RequestFailure ? { code: error.code, details: error.details } : {}
6594
7108
  });
6595
7109
  }
6596
7110
  } finally {
@@ -6642,11 +7156,29 @@ function createHttpServer(tools, bridge, allowedTools, serverConfig, security) {
6642
7156
  app.isMCPServerActive = isMCPServerActive;
6643
7157
  app.trackMCPActivity = trackMCPActivity;
6644
7158
  app.closeMcpHandler = () => mcpHandler?.close();
7159
+ app.attachStudioTransport = (server) => {
7160
+ if (closed)
7161
+ throw new Error("Cannot attach a closed Studio transport");
7162
+ if (boundServers.has(server))
7163
+ return;
7164
+ boundServers.add(server);
7165
+ server.on("upgrade", upgradeStudio);
7166
+ server.once("close", () => {
7167
+ boundServers.delete(server);
7168
+ server.removeListener("upgrade", upgradeStudio);
7169
+ });
7170
+ };
6645
7171
  app.cleanup = async () => {
6646
- for (const handle of eventStreamHandles)
6647
- handle.close();
6648
- eventStreamHandles.clear();
6649
- eventTransport.close();
7172
+ if (closed)
7173
+ return;
7174
+ closed = true;
7175
+ for (const server of boundServers)
7176
+ server.removeListener("upgrade", upgradeStudio);
7177
+ boundServers.clear();
7178
+ unsubscribePeerClosed();
7179
+ transportTokens.clear();
7180
+ studioTransport.close();
7181
+ webSocketServer.close();
6650
7182
  await mcpHandler?.close();
6651
7183
  };
6652
7184
  return app;
@@ -6668,18 +7200,26 @@ async function listenWithRetry(app, host, startPort, maxAttempts = 5) {
6668
7200
  throw new Error(`All ports ${startPort}-${startPort + maxAttempts - 1} are in use. Stop some MCP server instances and retry.`);
6669
7201
  }
6670
7202
  function bindPort(app, host, port) {
6671
- return new Promise((resolve5, reject) => {
6672
- const server = http.createServer(app);
6673
- const onError = (err2) => {
6674
- server.removeListener("error", onError);
6675
- reject(err2);
6676
- };
6677
- server.once("error", onError);
6678
- server.listen(port, host, () => {
6679
- server.removeListener("error", onError);
7203
+ const { promise, resolve: resolve5, reject } = Promise.withResolvers();
7204
+ const server = http.createServer(app);
7205
+ const onError = (err2) => {
7206
+ server.removeListener("error", onError);
7207
+ reject(err2);
7208
+ };
7209
+ server.once("error", onError);
7210
+ server.listen(port, host, () => {
7211
+ server.removeListener("error", onError);
7212
+ try {
7213
+ if ("attachStudioTransport" in app && typeof app.attachStudioTransport === "function") {
7214
+ app.attachStudioTransport(server);
7215
+ }
6680
7216
  resolve5(server);
6681
- });
7217
+ } catch (error) {
7218
+ server.close();
7219
+ reject(error);
7220
+ }
6682
7221
  });
7222
+ return promise;
6683
7223
  }
6684
7224
 
6685
7225
  // ../core/dist/tools/studio-client.js
@@ -6688,15 +7228,8 @@ var StudioHttpClient = class {
6688
7228
  constructor(bridge) {
6689
7229
  this.bridge = bridge;
6690
7230
  }
6691
- async request(endpoint, data, targetPeerId, timeoutMs, signal) {
6692
- try {
6693
- return await this.bridge.sendRequest(endpoint, data, targetPeerId, timeoutMs, signal);
6694
- } catch (error) {
6695
- if (error instanceof Error && error.message === "Request timeout") {
6696
- throw new Error("Studio plugin connection timeout. Make sure the Roblox Studio plugin is running and activated.");
6697
- }
6698
- throw error;
6699
- }
7231
+ async request(endpoint, data, targetPeerId, timeoutMs, signal, operationId) {
7232
+ return this.bridge.sendRequest(endpoint, data, targetPeerId, timeoutMs, signal, operationId);
6700
7233
  }
6701
7234
  };
6702
7235
 
@@ -7521,7 +8054,7 @@ function decodeImagePathToRgba(imagePath) {
7521
8054
  }
7522
8055
 
7523
8056
  // ../core/dist/studio-skills.js
7524
- import { createHash as createHash2 } from "crypto";
8057
+ import { createHash as createHash3 } from "crypto";
7525
8058
  import { existsSync as existsSync4, readFileSync as readFileSync4, readdirSync as readdirSync2, statSync as statSync2 } from "fs";
7526
8059
  import * as path4 from "path";
7527
8060
 
@@ -8406,7 +8939,7 @@ function parseBuiltInStudioSkills(buffer) {
8406
8939
  hasCombinedDocument: group.combined !== void 0,
8407
8940
  content: selected.content,
8408
8941
  contentLength: Buffer.byteLength(selected.content, "utf8"),
8409
- contentSha256: createHash2("sha256").update(selected.content).digest("hex")
8942
+ contentSha256: createHash3("sha256").update(selected.content).digest("hex")
8410
8943
  };
8411
8944
  }).sort((left, right) => left.name.localeCompare(right.name));
8412
8945
  }
@@ -8473,7 +9006,7 @@ function loadBuiltInStudioSkills(bundlePath = resolveAssistantBundlePath()) {
8473
9006
  const value = {
8474
9007
  bundlePath,
8475
9008
  bundleModifiedAt: stats.mtime.toISOString(),
8476
- bundleSha256: createHash2("sha256").update(buffer).digest("hex"),
9009
+ bundleSha256: createHash3("sha256").update(buffer).digest("hex"),
8477
9010
  studioVersion,
8478
9011
  skills
8479
9012
  };
@@ -9506,6 +10039,7 @@ var MAX_DEVICE_MATRIX_ENTRIES = 6;
9506
10039
  var MAX_NETWORK_PACKET_LOSS_PERCENT = 0.5;
9507
10040
  var GREP_SCRIPTS_TIMEOUT_MS = 12e4;
9508
10041
  var MAX_GREP_PATTERN_UTF8_BYTES = 4096;
10042
+ var RUNTIME_LOG_PEER_TIMEOUT_MS = 5e3;
9509
10043
  var STUDIO_ASSISTANT_SOURCE_IMAGE_LABEL = "Studio Assistant Source Image";
9510
10044
  var CREATOR_STORE_SEARCH_TYPES = /* @__PURE__ */ new Set([
9511
10045
  "Audio",
@@ -10343,8 +10877,8 @@ var RobloxStudioTools = class {
10343
10877
  _peerForRoleInScope(instanceId, role) {
10344
10878
  return this.bridge.getPeersInScope(instanceId).find((peer) => peer.role === role);
10345
10879
  }
10346
- _requestPeer(endpoint, data, targetPeerId, timeoutMs, signal) {
10347
- return this.client.request(endpoint, data, targetPeerId, timeoutMs, signal);
10880
+ _requestPeer(endpoint, data, targetPeerId, timeoutMs, signal, operationId) {
10881
+ return this.client.request(endpoint, data, targetPeerId, timeoutMs, signal, operationId);
10348
10882
  }
10349
10883
  _request(endpoint, data, instanceId, role, timeoutMs, signal) {
10350
10884
  const peer = this._peerForRoleInScope(instanceId, role);
@@ -10358,7 +10892,7 @@ var RobloxStudioTools = class {
10358
10892
  return this._requestPeer(endpoint, data, peer.peerId, timeoutMs, signal);
10359
10893
  }
10360
10894
  // Resolve an optional Studio process plus role to one exact Peer and dispatch.
10361
- async _callSingle(endpoint, data, target, instance_id, timeoutMs, signal) {
10895
+ async _callSingle(endpoint, data, target, instance_id, timeoutMs, signal, operationId) {
10362
10896
  const resolved = this.bridge.resolveTarget({ instance_id, target });
10363
10897
  if (!resolved.ok)
10364
10898
  throw new RoutingFailure(resolved.error);
@@ -10369,7 +10903,7 @@ var RobloxStudioTools = class {
10369
10903
  data: this._routingErrorData()
10370
10904
  });
10371
10905
  }
10372
- return this._requestPeer(endpoint, data, resolved.targetPeerId, timeoutMs, signal);
10906
+ return this._requestPeer(endpoint, data, resolved.targetPeerId, timeoutMs, signal, operationId);
10373
10907
  }
10374
10908
  // Prefer the first client role in the selected process/group scope for live
10375
10909
  // viewport and input operations; otherwise retain the default Peer's Instance.
@@ -10768,11 +11302,11 @@ var RobloxStudioTools = class {
10768
11302
  ]
10769
11303
  };
10770
11304
  }
10771
- async setProperties(instancePath, properties, instance_id) {
11305
+ async setProperties(instancePath, properties, instance_id, operation_id) {
10772
11306
  if (!instancePath || !properties) {
10773
11307
  throw new Error("instancePath and properties are required for set_properties");
10774
11308
  }
10775
- const response = await this._callSingle("/api/set-properties", { instancePath, properties }, void 0, instance_id);
11309
+ const response = await this._callSingle("/api/set-properties", { instancePath, properties }, void 0, instance_id, void 0, void 0, operation_id);
10776
11310
  return { content: [{ type: "text", text: JSON.stringify(response) }] };
10777
11311
  }
10778
11312
  async getScriptSource(instancePath, startLine, endLine, instance_id) {
@@ -10902,9 +11436,6 @@ var RobloxStudioTools = class {
10902
11436
  }
10903
11437
  return this.setSelection(opts.paths, opts.mode, instance_id);
10904
11438
  }
10905
- if (!opts.path) {
10906
- throw new Error("selection action=view requires path");
10907
- }
10908
11439
  return this.focusViewport(opts.path, opts.from, opts.padding, opts.angleY, instance_id);
10909
11440
  }
10910
11441
  async getSelection(instance_id) {
@@ -10937,8 +11468,8 @@ var RobloxStudioTools = class {
10937
11468
  };
10938
11469
  }
10939
11470
  async focusViewport(instancePath, from, padding, angleY, instance_id) {
10940
- if (!instancePath) {
10941
- throw new Error("selection action=view requires path");
11471
+ if (instancePath !== void 0 && (typeof instancePath !== "string" || instancePath.length === 0)) {
11472
+ throw new Error("selection path must be a non-empty instance path when provided");
10942
11473
  }
10943
11474
  if (padding !== void 0 && (padding <= 0 || padding > 10)) {
10944
11475
  throw new Error("selection padding must be greater than 0 and at most 10");
@@ -10962,11 +11493,11 @@ var RobloxStudioTools = class {
10962
11493
  ]
10963
11494
  };
10964
11495
  }
10965
- async executeLuau(code, target, instance_id) {
11496
+ async executeLuau(code, target, instance_id, operation_id) {
10966
11497
  if (!code) {
10967
11498
  throw new Error("Code is required for execute_luau");
10968
11499
  }
10969
- const response = await this._callSingle("/api/execute-luau", { code }, target || "edit", instance_id);
11500
+ const response = await this._callSingle("/api/execute-luau", { code }, target || "edit", instance_id, void 0, void 0, operation_id);
10970
11501
  return {
10971
11502
  content: [
10972
11503
  {
@@ -11367,7 +11898,7 @@ var RobloxStudioTools = class {
11367
11898
  ]
11368
11899
  };
11369
11900
  }
11370
- async getRuntimeLogs(instance_id, multiplayer_group_id, cursor, cursor_by_instance, tail, filter) {
11901
+ async getRuntimeLogs(instance_id, multiplayer_group_id, cursor, cursor_by_instance, tail, filter, signal) {
11371
11902
  if (instance_id !== void 0 && multiplayer_group_id !== void 0) {
11372
11903
  throw new Error("get_runtime_logs accepts only one of instance_id or multiplayer_group_id.");
11373
11904
  }
@@ -11511,7 +12042,7 @@ var RobloxStudioTools = class {
11511
12042
  if (filter !== void 0)
11512
12043
  data.filter = filter;
11513
12044
  try {
11514
- const responseValue = await this.client.request("/api/get-runtime-logs", data, peer.peerId);
12045
+ const responseValue = await this.client.request("/api/get-runtime-logs", data, peer.peerId, RUNTIME_LOG_PEER_TIMEOUT_MS, signal);
11515
12046
  if (typeof responseValue !== "object" || responseValue === null || Array.isArray(responseValue)) {
11516
12047
  return {
11517
12048
  peerId: peer.peerId,
@@ -11538,6 +12069,8 @@ var RobloxStudioTools = class {
11538
12069
  nextSince: response.nextSince
11539
12070
  };
11540
12071
  } catch (error) {
12072
+ if (signal?.aborted)
12073
+ throw error;
11541
12074
  return { peerId: peer.peerId, role: peer.role, error: errorMessage(error) };
11542
12075
  }
11543
12076
  }));
@@ -12593,6 +13126,20 @@ var RobloxStudioTools = class {
12593
13126
  multiplayerGroups: this.bridge.getConnectedMultiplayerGroups()
12594
13127
  });
12595
13128
  }
13129
+ async getRequestStatus(request_id) {
13130
+ if (typeof request_id !== "string" || request_id.length === 0 || request_id.length > 128) {
13131
+ throw new Error("request_id must contain between 1 and 128 characters");
13132
+ }
13133
+ const status = await this.bridge.getRequestStatusEverywhere(request_id);
13134
+ if (status)
13135
+ return this._textResult({ ...status });
13136
+ return this._textResult({
13137
+ requestId: request_id,
13138
+ state: "unknown",
13139
+ outcome: "unknown",
13140
+ message: "No retained operation record. It may have expired, been evicted, or belonged to an earlier server session. Do not infer that the mutation was not executed."
13141
+ });
13142
+ }
12596
13143
  // === Asset Tools ===
12597
13144
  async searchAssets(assetType, query, maxResults, sortBy, robloxCreatedOnly) {
12598
13145
  const normalized = normalizeCreatorStoreSearch(assetType, query);
@@ -13435,6 +13982,42 @@ var RobloxStudioTools = class {
13435
13982
  // ../core/dist/proxy-bridge-service.js
13436
13983
  import { randomUUID as randomUUID4 } from "crypto";
13437
13984
  var PROXY_RESPONSE_GRACE_MS = 5e3;
13985
+ function parseRequestStatus(value) {
13986
+ if (!value || typeof value !== "object" || Array.isArray(value))
13987
+ throw new Error("Proxy returned an invalid request status");
13988
+ if (!("requestId" in value) || typeof value.requestId !== "string" || !("targetPeerId" in value) || typeof value.targetPeerId !== "string" || !("queuedAt" in value) || typeof value.queuedAt !== "number" || !("stage" in value) || !isRequestStage(value.stage) || !("state" in value) || value.state !== "pending" && value.state !== "timed_out" && value.state !== "aborted" && value.state !== "disconnected" && value.state !== "settled" || !("outcome" in value) || value.outcome !== "pending" && value.outcome !== "not_executed" && value.outcome !== "unknown" && value.outcome !== "success" && value.outcome !== "error")
13989
+ throw new Error("Proxy returned an invalid request status");
13990
+ const status = {
13991
+ requestId: value.requestId,
13992
+ targetPeerId: value.targetPeerId,
13993
+ queuedAt: value.queuedAt,
13994
+ stage: value.stage,
13995
+ state: value.state,
13996
+ outcome: value.outcome,
13997
+ ...parseObservations(value)
13998
+ };
13999
+ if ("dispatchedAt" in value && typeof value.dispatchedAt === "number")
14000
+ status.dispatchedAt = value.dispatchedAt;
14001
+ if ("settledAt" in value && typeof value.settledAt === "number")
14002
+ status.settledAt = value.settledAt;
14003
+ if ("waiterEndedAt" in value && typeof value.waiterEndedAt === "number")
14004
+ status.waiterEndedAt = value.waiterEndedAt;
14005
+ if ("response" in value)
14006
+ status.response = value.response;
14007
+ if ("error" in value)
14008
+ status.error = value.error;
14009
+ if ("resultUnavailable" in value) {
14010
+ const unavailable = value.resultUnavailable;
14011
+ if (!unavailable || typeof unavailable !== "object" || !("reason" in unavailable) || unavailable.reason !== "size_limit" && unavailable.reason !== "serialization_failed" && unavailable.reason !== "retention_capacity" || !("limitBytes" in unavailable) || typeof unavailable.limitBytes !== "number")
14012
+ throw new Error("Proxy returned invalid result availability");
14013
+ status.resultUnavailable = {
14014
+ reason: unavailable.reason,
14015
+ limitBytes: unavailable.limitBytes,
14016
+ ..."bytes" in unavailable && typeof unavailable.bytes === "number" ? { bytes: unavailable.bytes } : {}
14017
+ };
14018
+ }
14019
+ return status;
14020
+ }
13438
14021
  function peerPublicationChanged(previous, current) {
13439
14022
  return previous === void 0 || previous.transportPeerId !== current.transportPeerId || previous.instanceId !== current.instanceId || previous.multiplayerGroupId !== current.multiplayerGroupId || previous.role !== current.role || previous.placeId !== current.placeId || previous.placeName !== current.placeName || previous.placeKey !== current.placeKey || previous.dataModelName !== current.dataModelName || previous.isRunning !== current.isRunning || previous.pluginVersion !== current.pluginVersion || previous.pluginVariant !== current.pluginVariant || previous.serverVersion !== current.serverVersion;
13440
14023
  }
@@ -13587,16 +14170,52 @@ var ProxyBridgeService = class _ProxyBridgeService extends BridgeService {
13587
14170
  /** Called when this proxy is being discarded (e.g. promotion to primary
13588
14171
  replaced it). Stops the background refresh so it doesn't leak. */
13589
14172
  stop() {
13590
- if (this.refreshTimer) {
13591
- clearInterval(this.refreshTimer);
13592
- this.refreshTimer = void 0;
13593
- }
14173
+ clearInterval(this.refreshTimer);
14174
+ this.refreshTimer = void 0;
14175
+ }
14176
+ async getRequestStatusEverywhere(requestId) {
14177
+ const response = await fetch(`${this.primaryBaseUrl}/request-status?requestId=${encodeURIComponent(requestId)}`, {
14178
+ headers: this.authHeaders()
14179
+ });
14180
+ if (!response.ok)
14181
+ throw new Error(`Proxy request status failed (${response.status})`);
14182
+ const body = await response.json();
14183
+ if (!body || typeof body !== "object" || !("status" in body))
14184
+ throw new Error("Proxy returned an invalid request status response");
14185
+ if (body.status === null)
14186
+ return void 0;
14187
+ const status = parseRequestStatus(body.status);
14188
+ if (status.requestId !== requestId)
14189
+ throw new Error("Proxy returned status for a different request");
14190
+ return status;
13594
14191
  }
13595
- async sendRequest(endpoint, data, targetPeerId, timeoutMs = this.proxyRequestTimeout, signal) {
14192
+ async sendRequest(endpoint, data, targetPeerId, timeoutMs = this.proxyRequestTimeout, signal, operationId) {
14193
+ const requestId = operationId ?? randomUUID4();
14194
+ const details = { requestId, targetPeerId, stage: "queued", outcome: "not_executed", executionOutcome: "not_executed" };
14195
+ if (typeof requestId !== "string" || requestId.trim().length === 0 || requestId.length > 128) {
14196
+ throw new RequestFailure("operationId must be a nonempty string of at most 128 characters", "invalid_operation_id", details);
14197
+ }
13596
14198
  if (signal?.aborted)
13597
- throw new Error("Request aborted");
14199
+ throw new RequestFailure(`Request aborted: ${requestId}; queued; not_executed`, "request_aborted", details);
13598
14200
  const controller = new AbortController();
13599
14201
  const effectiveTimeoutMs = Math.max(1, timeoutMs);
14202
+ let requestBody;
14203
+ try {
14204
+ requestBody = JSON.stringify({
14205
+ endpoint,
14206
+ data,
14207
+ targetPeerId,
14208
+ proxyInstanceId: this.proxyInstanceId,
14209
+ timeoutMs: effectiveTimeoutMs,
14210
+ operationId: requestId
14211
+ });
14212
+ } catch {
14213
+ throw new RequestFailure(`Request ${requestId} cannot be serialized at proxy admission; queued; not_executed`, "request_serialization_failed", details);
14214
+ }
14215
+ const requestBytes = Buffer.byteLength(requestBody);
14216
+ if (requestBytes > HTTP_BODY_LIMIT_BYTES) {
14217
+ throw new RequestFailure(`Request ${requestId} is ${requestBytes} bytes at proxy_send; limit ${HTTP_BODY_LIMIT_BYTES} bytes; queued; not_executed`, "request_too_large", { ...details, bytes: requestBytes, limitBytes: HTTP_BODY_LIMIT_BYTES, transportStage: "proxy_send" });
14218
+ }
13600
14219
  let timedOut = false;
13601
14220
  const abortFromCaller = () => controller.abort();
13602
14221
  signal?.addEventListener("abort", abortFromCaller, { once: true });
@@ -13606,39 +14225,54 @@ var ProxyBridgeService = class _ProxyBridgeService extends BridgeService {
13606
14225
  timedOut = true;
13607
14226
  controller.abort();
13608
14227
  }, effectiveTimeoutMs + PROXY_RESPONSE_GRACE_MS);
14228
+ let primaryError = false;
13609
14229
  try {
13610
14230
  const response = await fetch(`${this.primaryBaseUrl}/proxy`, {
13611
14231
  method: "POST",
13612
14232
  headers: this.authHeaders({ "Content-Type": "application/json" }),
13613
- body: JSON.stringify({
13614
- endpoint,
13615
- data,
13616
- targetPeerId,
13617
- proxyInstanceId: this.proxyInstanceId,
13618
- timeoutMs: effectiveTimeoutMs
13619
- }),
14233
+ body: requestBody,
13620
14234
  signal: controller.signal
13621
14235
  });
13622
- if (!response.ok) {
13623
- const body = await response.text();
13624
- throw new Error(`Proxy request failed (${response.status}): ${body}`);
14236
+ const body = await response.text();
14237
+ let result;
14238
+ try {
14239
+ result = JSON.parse(body);
14240
+ } catch {
14241
+ throw new Error(`Proxy request failed (${response.status}): ${body || response.statusText}`);
13625
14242
  }
13626
- const result = await response.json();
13627
14243
  if (!result || typeof result !== "object" || Array.isArray(result)) {
13628
14244
  throw new Error("Proxy returned an invalid response");
13629
14245
  }
13630
- if ("error" in result && typeof result.error === "string" && result.error.length > 0) {
13631
- throw new Error(result.error);
14246
+ if ("error" in result && result.error !== void 0 && typeof result.error !== "string") {
14247
+ primaryError = true;
14248
+ throw result.error;
14249
+ }
14250
+ if ("error" in result && typeof result.error === "string") {
14251
+ const details2 = "details" in result ? parseFailureDetails(result.details, { requestId, targetPeerId }) : void 0;
14252
+ if (details2 && "code" in result && typeof result.code === "string") {
14253
+ throw new RequestFailure(result.error, result.code, details2);
14254
+ }
14255
+ throw new RequestFailure(result.error, "studio_response_error", {
14256
+ requestId,
14257
+ targetPeerId,
14258
+ stage: "dispatched",
14259
+ outcome: "unknown",
14260
+ executionOutcome: "unknown"
14261
+ });
14262
+ }
14263
+ if (!response.ok) {
14264
+ throw new Error(`Proxy request failed (${response.status}): ${body || response.statusText}`);
13632
14265
  }
13633
14266
  return "response" in result ? result.response : void 0;
13634
14267
  } catch (error) {
14268
+ if (error instanceof RequestFailure || primaryError)
14269
+ throw error;
13635
14270
  const isAbortError = error instanceof Error ? error.name === "AbortError" : !!error && typeof error === "object" && "name" in error && error.name === "AbortError";
13636
14271
  if (isAbortError) {
13637
- if (!timedOut && signal?.aborted)
13638
- throw new Error("Request aborted");
13639
- throw new Error("Proxy request timeout");
14272
+ const message = !timedOut && signal?.aborted ? "Request aborted" : "Proxy request timeout";
14273
+ throw new RequestFailure(`${message}: ${requestId}; dispatched to primary; unknown; use get_request_status`, !timedOut && signal?.aborted ? "request_aborted" : "proxy_request_timeout", { requestId, targetPeerId, stage: "dispatched", outcome: "unknown", executionOutcome: "unknown", transportStage: "proxy_send" });
13640
14274
  }
13641
- throw error;
14275
+ throw new RequestFailure(`Proxy connection lost: ${requestId}; dispatched to primary; unknown; use get_request_status. ${error instanceof Error ? error.message : String(error)}`, "proxy_connection_lost", { requestId, targetPeerId, stage: "dispatched", outcome: "unknown", executionOutcome: "unknown", connectionLostAt: Date.now(), transportStage: "proxy_send" });
13642
14276
  } finally {
13643
14277
  clearTimeout(timeoutId);
13644
14278
  signal?.removeEventListener("abort", abortFromCaller);
@@ -13650,6 +14284,251 @@ var ProxyBridgeService = class _ProxyBridgeService extends BridgeService {
13650
14284
  }
13651
14285
  };
13652
14286
 
14287
+ // ../core/dist/stdio-transport.js
14288
+ import { parseJSONRPCMessage } from "@modelcontextprotocol/server";
14289
+ import { TextDecoder } from "util";
14290
+ var MAX_STDIO_LINE_BYTES = 80 * 1024 * 1024;
14291
+ var MAX_STDIO_PENDING_OUTPUT_BYTES = 256 * 1024 * 1024;
14292
+ var INITIAL_BUFFER_BYTES = 64 * 1024;
14293
+ var BoundedStdioTransport = class {
14294
+ input;
14295
+ output;
14296
+ onclose;
14297
+ onerror;
14298
+ onmessage;
14299
+ limitBytes;
14300
+ outputLimitBytes;
14301
+ decoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
14302
+ buffer;
14303
+ lineBytes = 0;
14304
+ started = false;
14305
+ closed = false;
14306
+ inputEnded = false;
14307
+ outputBlocked = false;
14308
+ pendingChunk;
14309
+ pendingWrites = 0;
14310
+ pendingSends = /* @__PURE__ */ new Set();
14311
+ constructor(input = process.stdin, output = process.stdout, options = {}) {
14312
+ this.input = input;
14313
+ this.output = output;
14314
+ this.limitBytes = options.maxBufferSize ?? MAX_STDIO_LINE_BYTES;
14315
+ this.outputLimitBytes = options.maxPendingOutputBytes ?? MAX_STDIO_PENDING_OUTPUT_BYTES;
14316
+ if (!Number.isSafeInteger(this.limitBytes) || this.limitBytes < 1) {
14317
+ throw new RangeError("Stdio line limit must be a positive safe integer");
14318
+ }
14319
+ if (!Number.isSafeInteger(this.outputLimitBytes) || this.outputLimitBytes < 1) {
14320
+ throw new RangeError("Stdio output limit must be a positive safe integer");
14321
+ }
14322
+ }
14323
+ async start() {
14324
+ if (this.started || this.closed)
14325
+ throw new Error("Stdio transport cannot be started again");
14326
+ if (this.input.readableEncoding)
14327
+ throw new Error("Stdio transport requires a byte stream, not a decoded string stream");
14328
+ this.started = true;
14329
+ this.input.on("data", this.onData);
14330
+ this.input.on("error", this.onStreamError);
14331
+ this.input.on("end", this.onEnd);
14332
+ this.input.on("close", this.onInputClose);
14333
+ this.output.on("error", this.onStreamError);
14334
+ this.output.on("close", this.onOutputClose);
14335
+ this.output.on("drain", this.onDrain);
14336
+ }
14337
+ onData = (chunk) => {
14338
+ let offset = 0;
14339
+ while (offset < chunk.length && !this.closed) {
14340
+ if (this.outputBlocked) {
14341
+ this.pendingChunk = chunk.subarray(offset);
14342
+ return;
14343
+ }
14344
+ const newline = chunk.indexOf(10, offset);
14345
+ const end = newline === -1 ? chunk.length : newline;
14346
+ const bytes = end - offset;
14347
+ const previousBytes = this.lineBytes;
14348
+ this.lineBytes += bytes;
14349
+ if (this.lineBytes > this.limitBytes) {
14350
+ this.buffer = void 0;
14351
+ } else if (previousBytes === 0 && newline !== -1) {
14352
+ this.buffer = chunk.subarray(offset, end);
14353
+ } else if (bytes > 0) {
14354
+ if (!this.buffer || this.buffer.length < this.lineBytes) {
14355
+ const capacity = Math.min(this.limitBytes, Math.max(INITIAL_BUFFER_BYTES, this.lineBytes, (this.buffer?.length ?? 0) * 2));
14356
+ const grown = Buffer.allocUnsafe(capacity);
14357
+ this.buffer?.copy(grown, 0, 0, previousBytes);
14358
+ this.buffer = grown;
14359
+ }
14360
+ chunk.copy(this.buffer, previousBytes, offset, end);
14361
+ }
14362
+ offset = end + 1;
14363
+ if (newline !== -1)
14364
+ this.finishLine();
14365
+ }
14366
+ };
14367
+ finishLine() {
14368
+ const bytes = this.lineBytes;
14369
+ const buffer = this.buffer;
14370
+ this.lineBytes = 0;
14371
+ this.buffer = void 0;
14372
+ if (bytes > this.limitBytes) {
14373
+ void this.rejectLine(-32600, "stdio_request_too_large", bytes).catch(() => {
14374
+ });
14375
+ return;
14376
+ }
14377
+ let value;
14378
+ try {
14379
+ value = JSON.parse(this.decoder.decode(buffer?.subarray(0, bytes)));
14380
+ } catch {
14381
+ void this.rejectLine(-32700, "stdio_parse_error", bytes).catch(() => {
14382
+ });
14383
+ return;
14384
+ }
14385
+ let message;
14386
+ try {
14387
+ message = parseJSONRPCMessage(value);
14388
+ } catch {
14389
+ void this.rejectLine(-32600, "stdio_invalid_request", bytes).catch(() => {
14390
+ });
14391
+ return;
14392
+ }
14393
+ try {
14394
+ this.onmessage?.(message);
14395
+ } catch (error) {
14396
+ this.onerror?.(error instanceof Error ? error : new Error(String(error)));
14397
+ }
14398
+ }
14399
+ rejectLine(code, reason, bytes) {
14400
+ const message = `${reason}: received ${bytes} bytes; limit ${this.limitBytes} bytes; stdio_receive; not_executed`;
14401
+ this.onerror?.(new Error(message));
14402
+ return this.writeMessage({
14403
+ jsonrpc: "2.0",
14404
+ id: null,
14405
+ error: {
14406
+ code,
14407
+ message,
14408
+ data: {
14409
+ code: reason,
14410
+ bytes,
14411
+ limitBytes: this.limitBytes,
14412
+ stage: "stdio_receive",
14413
+ transportStage: "stdio_receive",
14414
+ outcome: "not_executed",
14415
+ executionOutcome: "not_executed"
14416
+ }
14417
+ }
14418
+ });
14419
+ }
14420
+ onEnd = () => {
14421
+ if (this.closed)
14422
+ return;
14423
+ this.inputEnded = true;
14424
+ if (this.pendingChunk)
14425
+ return;
14426
+ const bytes = this.lineBytes;
14427
+ this.lineBytes = 0;
14428
+ this.buffer = void 0;
14429
+ if (bytes > 0) {
14430
+ const oversized = bytes > this.limitBytes;
14431
+ void this.rejectLine(oversized ? -32600 : -32700, oversized ? "stdio_request_too_large" : "stdio_truncated_line", bytes).finally(() => this.close()).catch(() => {
14432
+ });
14433
+ } else {
14434
+ void this.close();
14435
+ }
14436
+ };
14437
+ onInputClose = () => {
14438
+ if (!this.input.readableEnded)
14439
+ this.onEnd();
14440
+ };
14441
+ onOutputClose = () => {
14442
+ void this.close();
14443
+ };
14444
+ onStreamError = (error) => {
14445
+ if (this.closed)
14446
+ return;
14447
+ this.onerror?.(error);
14448
+ void this.close();
14449
+ };
14450
+ onDrain = () => {
14451
+ this.outputBlocked = false;
14452
+ for (const send of this.pendingSends)
14453
+ send.resolve();
14454
+ this.pendingSends.clear();
14455
+ const chunk = this.pendingChunk;
14456
+ this.pendingChunk = void 0;
14457
+ if (chunk && !this.closed)
14458
+ this.onData(chunk);
14459
+ if (this.inputEnded && !this.pendingChunk && !this.closed)
14460
+ this.onEnd();
14461
+ if (!this.outputBlocked && !this.closed)
14462
+ this.input.resume();
14463
+ };
14464
+ send(message) {
14465
+ return this.writeMessage(message);
14466
+ }
14467
+ writeMessage(message) {
14468
+ if (this.closed)
14469
+ return Promise.reject(new Error("Stdio transport is closed"));
14470
+ let json;
14471
+ try {
14472
+ json = JSON.stringify(message) + "\n";
14473
+ } catch (error) {
14474
+ return Promise.reject(error);
14475
+ }
14476
+ const bytes = Buffer.byteLength(json);
14477
+ const queuedBytes = this.output.writableLength + bytes;
14478
+ if (queuedBytes > this.outputLimitBytes) {
14479
+ const error = new Error(`Stdio output backpressure capacity exceeded: ${queuedBytes} bytes; limit ${this.outputLimitBytes} bytes`);
14480
+ this.onStreamError(error);
14481
+ return Promise.reject(error);
14482
+ }
14483
+ const { promise, resolve: resolve5, reject } = Promise.withResolvers();
14484
+ const pending = { resolve: resolve5, reject };
14485
+ this.pendingSends.add(pending);
14486
+ this.pendingWrites++;
14487
+ try {
14488
+ const writable = this.output.write(Buffer.from(json), () => {
14489
+ this.pendingWrites--;
14490
+ if (this.closed && this.pendingWrites === 0) {
14491
+ queueMicrotask(() => this.output.off("error", this.onStreamError));
14492
+ }
14493
+ });
14494
+ if (writable) {
14495
+ this.pendingSends.delete(pending);
14496
+ resolve5();
14497
+ } else if (!this.closed) {
14498
+ this.outputBlocked = true;
14499
+ this.input.pause();
14500
+ }
14501
+ } catch (error) {
14502
+ this.pendingWrites--;
14503
+ this.onStreamError(error instanceof Error ? error : new Error(String(error)));
14504
+ }
14505
+ return promise;
14506
+ }
14507
+ async close() {
14508
+ if (this.closed)
14509
+ return;
14510
+ this.closed = true;
14511
+ this.input.off("data", this.onData);
14512
+ this.input.off("error", this.onStreamError);
14513
+ this.input.off("end", this.onEnd);
14514
+ this.input.off("close", this.onInputClose);
14515
+ if (this.pendingWrites === 0)
14516
+ this.output.off("error", this.onStreamError);
14517
+ this.output.off("close", this.onOutputClose);
14518
+ this.output.off("drain", this.onDrain);
14519
+ if (this.input.listenerCount("data") === 0)
14520
+ this.input.pause();
14521
+ this.buffer = void 0;
14522
+ this.pendingChunk = void 0;
14523
+ this.lineBytes = 0;
14524
+ const error = new Error("Stdio transport is closed");
14525
+ for (const send of this.pendingSends)
14526
+ send.reject(error);
14527
+ this.pendingSends.clear();
14528
+ this.onclose?.();
14529
+ }
14530
+ };
14531
+
13653
14532
  // ../core/dist/server.js
13654
14533
  var RobloxStudioMCPServer = class {
13655
14534
  tools;
@@ -13743,7 +14622,10 @@ var RobloxStudioMCPServer = class {
13743
14622
  throw new Error(`Unknown tool: ${name}`);
13744
14623
  return handler(tools, args, invocation);
13745
14624
  }
13746
- }), { onerror: (error) => console.error("[mcp:stdio]", error) });
14625
+ }), {
14626
+ transport: new BoundedStdioTransport(),
14627
+ onerror: (error) => console.error("[mcp:stdio]", error)
14628
+ });
13747
14629
  console.error(`${this.config.name} v${this.config.version} running on stdio`);
13748
14630
  if (primaryApp) {
13749
14631
  primaryApp.setMCPServerActive(true);
@@ -13901,6 +14783,12 @@ var TOOL_DEFINITIONS = [
13901
14783
  inputSchema: {
13902
14784
  type: "object",
13903
14785
  properties: {
14786
+ operation_id: {
14787
+ type: "string",
14788
+ minLength: 1,
14789
+ maxLength: 128,
14790
+ description: "Unique recovery ID; reuse only for identical arguments."
14791
+ },
13904
14792
  instancePath: {
13905
14793
  type: "string",
13906
14794
  description: "Canonical path of the target instance."
@@ -14076,7 +14964,7 @@ var TOOL_DEFINITIONS = [
14076
14964
  action: {
14077
14965
  type: "string",
14078
14966
  enum: ["get", "set", "view"],
14079
- description: "View frames the target."
14967
+ description: "View frames a target or the current selection and preserves the camera type."
14080
14968
  },
14081
14969
  paths: {
14082
14970
  type: "array",
@@ -14092,7 +14980,7 @@ var TOOL_DEFINITIONS = [
14092
14980
  path: {
14093
14981
  type: "string",
14094
14982
  minLength: 1,
14095
- description: "View needs a BasePart or Model path."
14983
+ description: "Optional BasePart or Model path for view; omit to frame exactly one selected BasePart or Model."
14096
14984
  },
14097
14985
  from: {
14098
14986
  type: "number",
@@ -14126,6 +15014,12 @@ var TOOL_DEFINITIONS = [
14126
15014
  inputSchema: {
14127
15015
  type: "object",
14128
15016
  properties: {
15017
+ operation_id: {
15018
+ type: "string",
15019
+ minLength: 1,
15020
+ maxLength: 128,
15021
+ description: "Unique recovery ID; reuse only for identical arguments."
15022
+ },
14129
15023
  code: {
14130
15024
  type: "string",
14131
15025
  description: "Luau code to execute."
@@ -14970,6 +15864,23 @@ var TOOL_DEFINITIONS = [
14970
15864
  properties: {}
14971
15865
  }
14972
15866
  },
15867
+ {
15868
+ name: "get_request_status",
15869
+ category: "read",
15870
+ description: "Use to recover a retained Studio operation outcome after a timeout.",
15871
+ inputSchema: {
15872
+ type: "object",
15873
+ properties: {
15874
+ request_id: {
15875
+ type: "string",
15876
+ minLength: 1,
15877
+ maxLength: 128,
15878
+ description: "Caller operation ID or request ID from a server error."
15879
+ }
15880
+ },
15881
+ required: ["request_id"]
15882
+ }
15883
+ },
14973
15884
  // === Asset Tools ===
14974
15885
  {
14975
15886
  name: "search_assets",