@chrrxs/robloxstudio-mcp-inspector 2.22.0 → 2.22.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
@@ -32,7 +32,7 @@ function publishedInstanceId(placeId) {
32
32
  return void 0;
33
33
  return `place:${Math.trunc(placeId)}`;
34
34
  }
35
- var RoutingFailure, STALE_INSTANCE_MS, INSTANCE_ALIAS_TTL_MS, BridgeService;
35
+ var RoutingFailure, STALE_INSTANCE_MS, DUPLICATE_TAKEOVER_MS, INSTANCE_ALIAS_TTL_MS, BridgeService;
36
36
  var init_bridge_service = __esm({
37
37
  "../core/dist/bridge-service.js"() {
38
38
  "use strict";
@@ -45,6 +45,7 @@ var init_bridge_service = __esm({
45
45
  }
46
46
  };
47
47
  STALE_INSTANCE_MS = 3e4;
48
+ DUPLICATE_TAKEOVER_MS = 3e3;
48
49
  INSTANCE_ALIAS_TTL_MS = 5 * 60 * 1e3;
49
50
  BridgeService = class {
50
51
  pendingRequests = /* @__PURE__ */ new Map();
@@ -167,14 +168,18 @@ var init_bridge_service = __esm({
167
168
  }
168
169
  const existing = Array.from(this.instances.values()).find((i) => i.instanceId === instanceId && i.role === assignedRole && i.pluginSessionId !== pluginSessionId);
169
170
  if (existing) {
170
- return {
171
- ok: false,
172
- error: {
173
- code: "duplicate_instance_role",
174
- message: `Another plugin is already registered as (${instanceId}, ${assignedRole}).`,
175
- existing: toPublic(existing)
176
- }
177
- };
171
+ if (Date.now() - existing.lastActivity > DUPLICATE_TAKEOVER_MS) {
172
+ this.unregisterInstance(existing.pluginSessionId);
173
+ } else {
174
+ return {
175
+ ok: false,
176
+ error: {
177
+ code: "duplicate_instance_role",
178
+ message: `Another plugin is already registered as (${instanceId}, ${assignedRole}).`,
179
+ existing: toPublic(existing)
180
+ }
181
+ };
182
+ }
178
183
  }
179
184
  const registered = {
180
185
  pluginSessionId,
@@ -501,6 +506,93 @@ function cacheSet(key, entry) {
501
506
  function docUrl(category, name) {
502
507
  return `${DOCS_BASE_URL}/${category}/${encodeURIComponent(name)}.md`;
503
508
  }
509
+ function normalizeDocName(value) {
510
+ return value.toLowerCase().replace(/[^a-z0-9]/g, "");
511
+ }
512
+ function queryNameVariants(value) {
513
+ const parts = value.split(/[./:#\s]+/).filter(Boolean);
514
+ const variants = /* @__PURE__ */ new Set([normalizeDocName(value)]);
515
+ for (const part of parts) {
516
+ const normalized = normalizeDocName(part);
517
+ if (normalized && normalized !== "enum")
518
+ variants.add(normalized);
519
+ }
520
+ return Array.from(variants).filter(Boolean);
521
+ }
522
+ function editDistance(left, right) {
523
+ const previous = Array.from({ length: right.length + 1 }, (_, index) => index);
524
+ for (let i = 1; i <= left.length; i++) {
525
+ let diagonal = previous[0];
526
+ previous[0] = i;
527
+ for (let j = 1; j <= right.length; j++) {
528
+ const above = previous[j];
529
+ previous[j] = Math.min(previous[j] + 1, previous[j - 1] + 1, diagonal + (left[i - 1] === right[j - 1] ? 0 : 1));
530
+ diagonal = above;
531
+ }
532
+ }
533
+ return previous[right.length];
534
+ }
535
+ async function fetchDocCatalog() {
536
+ if (catalogCache && Date.now() - catalogCache.fetchedAt <= CACHE_TTL_MS) {
537
+ return catalogCache.pages;
538
+ }
539
+ const controller = new AbortController();
540
+ const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
541
+ let response;
542
+ try {
543
+ response = await fetch(DOCS_INDEX_URL, {
544
+ signal: controller.signal,
545
+ headers: { Accept: "text/html" }
546
+ });
547
+ } finally {
548
+ clearTimeout(timer);
549
+ }
550
+ if (!response.ok) {
551
+ throw new Error(`Failed to fetch Roblox docs index: HTTP ${response.status}`);
552
+ }
553
+ const html = await response.text();
554
+ const pages = /* @__PURE__ */ new Map();
555
+ const routePattern = /reference\/engine\/(classes|enums|datatypes|libraries|globals)\/([A-Za-z0-9_]+)/g;
556
+ for (const match of html.matchAll(routePattern)) {
557
+ const category = match[1];
558
+ const name = match[2];
559
+ const key = `${category}/${name}`;
560
+ pages.set(key, { category, name, url: docUrl(category, name) });
561
+ }
562
+ const result = Array.from(pages.values());
563
+ if (result.length === 0) {
564
+ throw new Error("Roblox docs index did not contain any engine reference pages");
565
+ }
566
+ catalogCache = { fetchedAt: Date.now(), pages: result };
567
+ return result;
568
+ }
569
+ async function recommendRobloxDocs(category, name, limit = 5) {
570
+ const variants = queryNameVariants(name);
571
+ const catalog = await fetchDocCatalog();
572
+ return catalog.map((page) => {
573
+ const candidate = normalizeDocName(page.name);
574
+ const similarity = Math.max(...variants.map((variant) => {
575
+ if (variant === candidate)
576
+ return 2;
577
+ const distance = editDistance(variant, candidate);
578
+ const ratio = 1 - distance / Math.max(variant.length, candidate.length, 1);
579
+ const prefixBonus = variant.startsWith(candidate) || candidate.startsWith(variant) ? 0.15 : 0;
580
+ const containsBonus = variant.includes(candidate) || candidate.includes(variant) ? 0.05 : 0;
581
+ return ratio + prefixBonus + containsBonus;
582
+ }));
583
+ return { page, score: similarity + (page.category === category ? 0.08 : 0) };
584
+ }).sort((a, b) => b.score - a.score || a.page.name.localeCompare(b.page.name)).slice(0, Math.max(1, limit)).map(({ page }) => page);
585
+ }
586
+ function recommendationsMarkdown(requestedCategory, requestedName, recommendations) {
587
+ const lines = recommendations.map((page) => `- [${page.category}/${page.name}](${page.url}) \u2014 retry with \`name="${page.name}", doc_type="${page.category}"\``);
588
+ return [
589
+ `# No exact Roblox documentation page found`,
590
+ "",
591
+ `The lookup \`${requestedCategory}/${requestedName}\` did not resolve. Recommended pages:`,
592
+ "",
593
+ ...lines
594
+ ].join("\n");
595
+ }
504
596
  async function fetchRobloxDoc(category, name) {
505
597
  const key = `${category}/${name}`;
506
598
  const cached = cacheGet(key);
@@ -565,7 +657,24 @@ function extractSection(markdown, section) {
565
657
  return lines.slice(start, end).join("\n").trimEnd();
566
658
  }
567
659
  async function getRobloxDoc(category, name, section) {
568
- const markdown = await fetchRobloxDoc(category, name);
660
+ let markdown;
661
+ try {
662
+ markdown = await fetchRobloxDoc(category, name);
663
+ } catch (error) {
664
+ if (!(error instanceof DocNotFoundError))
665
+ throw error;
666
+ try {
667
+ const recommendations = await recommendRobloxDocs(category, name);
668
+ return {
669
+ content: recommendationsMarkdown(category, name, recommendations),
670
+ truncated: false,
671
+ sections: [],
672
+ recommendations
673
+ };
674
+ } catch {
675
+ throw error;
676
+ }
677
+ }
569
678
  const sections = listSections(markdown);
570
679
  if (section) {
571
680
  const extracted = extractSection(markdown, section);
@@ -584,12 +693,13 @@ async function getRobloxDoc(category, name, section) {
584
693
  }
585
694
  return { content: markdown, truncated: false, sections };
586
695
  }
587
- var DOC_CATEGORIES, DOCS_BASE_URL, FETCH_TIMEOUT_MS, CACHE_TTL_MS, NEGATIVE_CACHE_TTL_MS, MAX_CACHE_ENTRIES, MAX_DOC_CHARS, cache, DocNotFoundError;
696
+ var DOC_CATEGORIES, DOCS_BASE_URL, DOCS_INDEX_URL, FETCH_TIMEOUT_MS, CACHE_TTL_MS, NEGATIVE_CACHE_TTL_MS, MAX_CACHE_ENTRIES, MAX_DOC_CHARS, cache, catalogCache, DocNotFoundError;
588
697
  var init_roblox_docs = __esm({
589
698
  "../core/dist/roblox-docs.js"() {
590
699
  "use strict";
591
700
  DOC_CATEGORIES = ["classes", "enums", "datatypes", "libraries", "globals"];
592
701
  DOCS_BASE_URL = "https://create.roblox.com/docs/reference/engine";
702
+ DOCS_INDEX_URL = DOCS_BASE_URL;
593
703
  FETCH_TIMEOUT_MS = 15e3;
594
704
  CACHE_TTL_MS = 24 * 60 * 60 * 1e3;
595
705
  NEGATIVE_CACHE_TTL_MS = 5 * 60 * 1e3;
@@ -13381,7 +13491,7 @@ part(0,2,0,2,1,1,"b")`,
13381
13491
  {
13382
13492
  name: "get_roblox_docs",
13383
13493
  category: "read",
13384
- description: 'Fetch official Roblox engine API documentation as markdown from create.roblox.com. Call this BEFORE writing or editing code that uses an engine class, enum, datatype, or Luau library you are not fully certain about (e.g. ProximityPrompt, Enum.KeyCode, CFrame, TweenService) \u2014 the page includes the description, properties, methods, events, and code samples. Results are cached, so repeat lookups are cheap. Very large pages are truncated with a section index; pass section (e.g. "Properties", "Methods", "Events") to read one section in full.',
13494
+ description: 'Fetch official Roblox engine API documentation as markdown from create.roblox.com. Call this BEFORE writing or editing code that uses an engine class, enum, datatype, or Luau library you are not fully certain about (e.g. ProximityPrompt, Enum.KeyCode, CFrame, TweenService) \u2014 the page includes the description, properties, methods, events, and code samples. Unresolved names return ranked recommendations from the official engine index, including pages in other doc categories. Results are cached, so repeat lookups are cheap. Very large pages are truncated with a section index; pass section (e.g. "Properties", "Methods", "Events") to read one section in full.',
13385
13495
  inputSchema: {
13386
13496
  type: "object",
13387
13497
  properties: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chrrxs/robloxstudio-mcp-inspector",
3
- "version": "2.22.0",
3
+ "version": "2.22.2",
4
4
  "description": "Read-only MCP server for inspecting and debugging Roblox Studio from AI coding tools",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",
@@ -712,11 +712,11 @@ local function computeInstanceId()
712
712
  return `anon:{fresh}`
713
713
  end
714
714
  local assignedRole
715
- local duplicateInstanceRole = false
716
715
  local hasVersionMismatch = false
717
716
  local lastVersionMismatchWarningKey
718
717
  local lastReadyInstanceId
719
718
  local readyFailureLogKeys = {}
719
+ local retryingDuplicateReady = false
720
720
  -- Cache the published place name from MarketplaceService:GetProductInfo so
721
721
  -- /ready can carry a friendly identifier (e.g. "Natural Disasters") distinct
722
722
  -- from game.Name (the DataModel name, often "Place1" in edit). We only fetch
@@ -843,19 +843,33 @@ local function processRequest(request)
843
843
  end
844
844
  end
845
845
  local function sendResponse(conn, requestId, responseData)
846
- pcall(function()
847
- HttpService:RequestAsync({
848
- Url = `{conn.serverUrl}/response`,
846
+ local responseUrl = `{conn.serverUrl}/response`
847
+ local encodeOk, encoded = pcall(function()
848
+ return HttpService:JSONEncode({
849
+ requestId = requestId,
850
+ response = responseData,
851
+ })
852
+ end)
853
+ local body = if encodeOk then encoded else HttpService:JSONEncode({
854
+ requestId = requestId,
855
+ error = `Plugin response serialization failed: {tostring(encoded)}`,
856
+ })
857
+ if not encodeOk then
858
+ warn(`[robloxstudio-mcp] Failed to serialize response {requestId}: {tostring(encoded)}`)
859
+ end
860
+ local requestOk, requestResult = pcall(function()
861
+ return HttpService:RequestAsync({
862
+ Url = responseUrl,
849
863
  Method = "POST",
850
864
  Headers = {
851
865
  ["Content-Type"] = "application/json",
852
866
  },
853
- Body = HttpService:JSONEncode({
854
- requestId = requestId,
855
- response = responseData,
856
- }),
867
+ Body = body,
857
868
  })
858
869
  end)
870
+ if not requestOk or not requestResult.Success then
871
+ warn(`[robloxstudio-mcp] Failed to deliver response {requestId}: {HttpDiagnostics.formatRequestFailure(responseUrl, requestOk, requestResult)}`)
872
+ end
859
873
  end
860
874
  local function getConnectionStatus()
861
875
  local conn = State.getActiveConnection()
@@ -906,11 +920,12 @@ local function ensureIdentityWatcher(conn)
906
920
  end
907
921
  end
908
922
  function sendReady(conn)
909
- if duplicateInstanceRole then
910
- return nil
911
- end
912
923
  local now = tick()
913
- if now - lastReadyPostAt < 2 then
924
+ -- Normal identity refreshes stay conservatively throttled. Once a stale
925
+ -- predecessor causes 409, retry once per second so takeover follows the
926
+ -- server's short inactivity lease without another two seconds of jitter.
927
+ local readyInterval = if retryingDuplicateReady then 1 else 2
928
+ if now - lastReadyPostAt < readyInterval then
914
929
  return nil
915
930
  end
916
931
  lastReadyPostAt = now
@@ -942,28 +957,39 @@ function sendReady(conn)
942
957
  local readyRole = detectRole()
943
958
  local readyLogKey = `{conn.serverUrl}|{instanceId}|{readyRole}`
944
959
  if not readyOk then
960
+ local shouldLog = not (readyFailureLogKeys[readyLogKey] ~= nil)
945
961
  readyFailureLogKeys[readyLogKey] = true
946
- warn(`[robloxstudio-mcp] /ready failed for {instanceId}/{readyRole}: {HttpDiagnostics.formatRequestFailure(readyUrl, readyOk, readyResult)}`)
962
+ if shouldLog then
963
+ warn(`[robloxstudio-mcp] /ready failed for {instanceId}/{readyRole}: {HttpDiagnostics.formatRequestFailure(readyUrl, readyOk, readyResult)}`)
964
+ end
947
965
  return nil
948
966
  end
949
967
  if not readyResult.Success then
950
968
  local reason = HttpDiagnostics.formatRequestFailure(readyUrl, true, readyResult)
969
+ local shouldLog = not (readyFailureLogKeys[readyLogKey] ~= nil)
951
970
  readyFailureLogKeys[readyLogKey] = true
952
- -- 409 = duplicate_instance_role. Surface in UI and stop polling.
971
+ -- A predecessor can remain registered briefly when Studio exits before
972
+ -- its asynchronous /disconnect completes. Keep polling and retrying
973
+ -- /ready: the server will take us over once the predecessor has stopped
974
+ -- polling, while a genuinely active duplicate continues to hold routing.
953
975
  if readyResult.StatusCode == 409 then
954
- duplicateInstanceRole = true
955
- conn.isActive = false
976
+ retryingDuplicateReady = true
956
977
  local ui = UI.getElements()
957
- ui.statusLabel.Text = "Duplicate instance"
958
- ui.statusLabel.TextColor3 = Color3.fromRGB(239, 68, 68)
978
+ ui.statusLabel.Text = "Waiting for previous instance"
979
+ ui.statusLabel.TextColor3 = Color3.fromRGB(245, 158, 11)
959
980
  ui.detailStatusLabel.Text = reason
960
- ui.detailStatusLabel.TextColor3 = Color3.fromRGB(239, 68, 68)
961
- warn(`[robloxstudio-mcp] /ready rejected for {instanceId}/{readyRole}: {reason}`)
981
+ ui.detailStatusLabel.TextColor3 = Color3.fromRGB(245, 158, 11)
982
+ if shouldLog then
983
+ warn(`[robloxstudio-mcp] /ready rejected for {instanceId}/{readyRole}: {reason}`)
984
+ end
962
985
  return nil
963
986
  end
964
- warn(`[robloxstudio-mcp] /ready rejected for {instanceId}/{readyRole}: {reason}`)
987
+ if shouldLog then
988
+ warn(`[robloxstudio-mcp] /ready rejected for {instanceId}/{readyRole}: {reason}`)
989
+ end
965
990
  return nil
966
991
  end
992
+ retryingDuplicateReady = false
967
993
  local parseOk, readyData = pcall(function()
968
994
  return HttpService:JSONDecode(readyResult.Body)
969
995
  end)
@@ -1413,9 +1439,9 @@ local function computeBridgeStamp()
1413
1439
  for i = 1, #combined do
1414
1440
  h = (h * 33 + (string.byte(combined, i))) % 2147483647
1415
1441
  end
1416
- -- "2.22.0" is replaced with the package version at package time
1442
+ -- "2.22.2" is replaced with the package version at package time
1417
1443
  -- (scripts/build-plugin.mjs injectVersion), so a release bump also restamps.
1418
- return `{tostring(h)}-2.22.0`
1444
+ return `{tostring(h)}-2.22.2`
1419
1445
  end
1420
1446
  local BRIDGE_STAMP = computeBridgeStamp()
1421
1447
  local function setSource(scriptInst, source)
@@ -10425,6 +10451,41 @@ end
10425
10451
  local function nowSec()
10426
10452
  return DateTime.now().UnixTimestampMillis / 1000
10427
10453
  end
10454
+ -- Studio occasionally exposes binary-bearing Output messages through
10455
+ -- LogService (for example, plugin hydration diagnostics containing raw CSG
10456
+ -- data). HttpService:JSONEncode rejects those strings outright. Preserve all
10457
+ -- valid UTF-8 verbatim and make only malformed bytes JSON-safe and visible.
10458
+ local function escapeInvalidUtf8(msg)
10459
+ local valid = utf8.len(msg)
10460
+ -- Roblox currently returns nil (not the false declared by @rbxts/types)
10461
+ -- when it encounters a malformed sequence. A numeric result is the only
10462
+ -- portable success discriminator across both representations.
10463
+ if type(valid) == "number" then
10464
+ return msg
10465
+ end
10466
+ local parts = {}
10467
+ local cursor = 1
10468
+ while cursor <= #msg do
10469
+ local suffixValid, invalidPosition = utf8.len(msg, cursor)
10470
+ if type(suffixValid) == "number" then
10471
+ local _arg0 = string.sub(msg, cursor)
10472
+ table.insert(parts, _arg0)
10473
+ break
10474
+ end
10475
+ if not (type(invalidPosition) == "number") then
10476
+ break
10477
+ end
10478
+ if invalidPosition > cursor then
10479
+ local _arg0 = string.sub(msg, cursor, invalidPosition - 1)
10480
+ table.insert(parts, _arg0)
10481
+ end
10482
+ local invalidByte = string.byte(msg, invalidPosition)
10483
+ local _arg0 = string.format("\\x%02X", invalidByte)
10484
+ table.insert(parts, _arg0)
10485
+ cursor = invalidPosition + 1
10486
+ end
10487
+ return table.concat(parts, "")
10488
+ end
10428
10489
  local function dropOldestUntilFits(incomingBytes)
10429
10490
  while #entries > 0 and (totalBytes + incomingBytes > MAX_BYTES or #entries >= HARD_ENTRY_CAP) do
10430
10491
  local dropped = table.remove(entries, 1)
@@ -10436,37 +10497,44 @@ local function pushEntry(msg, t, ts)
10436
10497
  if ts == nil then
10437
10498
  ts = nowSec()
10438
10499
  end
10439
- local bytes = #msg
10500
+ local safeMessage = escapeInvalidUtf8(msg)
10501
+ local bytes = #safeMessage
10440
10502
  dropOldestUntilFits(bytes)
10441
10503
  local _arg0 = {
10442
10504
  seq = nextSeq,
10443
10505
  ts = ts,
10444
10506
  level = levelTag(t),
10445
- message = msg,
10507
+ message = safeMessage,
10446
10508
  }
10447
10509
  table.insert(entries, _arg0)
10448
10510
  nextSeq += 1
10449
10511
  totalBytes += bytes
10450
10512
  end
10451
10513
  local function seedRuntimeHistory()
10452
- if not RunService:IsRunning() then
10453
- return nil
10454
- end
10455
10514
  local ok, history = pcall(function()
10456
10515
  return LogService:GetLogHistory()
10457
10516
  end)
10458
10517
  if not ok then
10459
10518
  return nil
10460
10519
  end
10520
+ local isEdit = not RunService:IsRunning()
10521
+ -- GetLogHistory timestamps and DateTime.now() share Unix time, while
10522
+ -- os.clock() is elapsed time for this Studio process. Their difference is
10523
+ -- therefore the process launch boundary. Edit-mode history is filtered to
10524
+ -- that boundary so startup errors from this launch are recovered without
10525
+ -- importing history left by an earlier Studio process.
10526
+ local processStartedAt = nowSec() - os.clock()
10461
10527
  for _, entry in history do
10462
10528
  local _message = entry.message
10463
10529
  if not (type(_message) == "string") then
10464
10530
  continue
10465
10531
  end
10466
- local _exp = entry.message
10467
- local _exp_1 = entry.messageType
10468
10532
  local _timestamp = entry.timestamp
10469
- pushEntry(_exp, _exp_1, if type(_timestamp) == "number" then entry.timestamp else nil)
10533
+ local timestamp = if type(_timestamp) == "number" then entry.timestamp else nil
10534
+ if isEdit and (timestamp == nil or timestamp < processStartedAt - 1) then
10535
+ continue
10536
+ end
10537
+ pushEntry(entry.message, entry.messageType, timestamp)
10470
10538
  end
10471
10539
  end
10472
10540
  local function install()
@@ -10477,9 +10545,9 @@ local function install()
10477
10545
  return nil
10478
10546
  end
10479
10547
  installed = true
10480
- -- Play peers can emit startup logs before the plugin finishes loading.
10481
- -- Seed from per-DataModel LogHistory so get_runtime_logs can still see
10482
- -- those early messages; skip edit mode to avoid stale prior-session logs.
10548
+ -- Every peer can emit startup logs before the plugin finishes loading.
10549
+ -- Seed from per-DataModel LogHistory so get_runtime_logs can still see them;
10550
+ -- edit history is bounded to the current Studio process above.
10483
10551
  seedRuntimeHistory()
10484
10552
  LogService.MessageOut:Connect(function(msg, t)
10485
10553
  pushEntry(msg, t)
@@ -10745,7 +10813,7 @@ return {
10745
10813
  <Properties>
10746
10814
  <string name="Name">State</string>
10747
10815
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
10748
- local CURRENT_VERSION = "2.22.0"
10816
+ local CURRENT_VERSION = "2.22.2"
10749
10817
  local PLUGIN_VARIANT = "inspector"
10750
10818
  local BASE_PORT = 58741
10751
10819
  local function createConnection(port)
@@ -712,11 +712,11 @@ local function computeInstanceId()
712
712
  return `anon:{fresh}`
713
713
  end
714
714
  local assignedRole
715
- local duplicateInstanceRole = false
716
715
  local hasVersionMismatch = false
717
716
  local lastVersionMismatchWarningKey
718
717
  local lastReadyInstanceId
719
718
  local readyFailureLogKeys = {}
719
+ local retryingDuplicateReady = false
720
720
  -- Cache the published place name from MarketplaceService:GetProductInfo so
721
721
  -- /ready can carry a friendly identifier (e.g. "Natural Disasters") distinct
722
722
  -- from game.Name (the DataModel name, often "Place1" in edit). We only fetch
@@ -843,19 +843,33 @@ local function processRequest(request)
843
843
  end
844
844
  end
845
845
  local function sendResponse(conn, requestId, responseData)
846
- pcall(function()
847
- HttpService:RequestAsync({
848
- Url = `{conn.serverUrl}/response`,
846
+ local responseUrl = `{conn.serverUrl}/response`
847
+ local encodeOk, encoded = pcall(function()
848
+ return HttpService:JSONEncode({
849
+ requestId = requestId,
850
+ response = responseData,
851
+ })
852
+ end)
853
+ local body = if encodeOk then encoded else HttpService:JSONEncode({
854
+ requestId = requestId,
855
+ error = `Plugin response serialization failed: {tostring(encoded)}`,
856
+ })
857
+ if not encodeOk then
858
+ warn(`[robloxstudio-mcp] Failed to serialize response {requestId}: {tostring(encoded)}`)
859
+ end
860
+ local requestOk, requestResult = pcall(function()
861
+ return HttpService:RequestAsync({
862
+ Url = responseUrl,
849
863
  Method = "POST",
850
864
  Headers = {
851
865
  ["Content-Type"] = "application/json",
852
866
  },
853
- Body = HttpService:JSONEncode({
854
- requestId = requestId,
855
- response = responseData,
856
- }),
867
+ Body = body,
857
868
  })
858
869
  end)
870
+ if not requestOk or not requestResult.Success then
871
+ warn(`[robloxstudio-mcp] Failed to deliver response {requestId}: {HttpDiagnostics.formatRequestFailure(responseUrl, requestOk, requestResult)}`)
872
+ end
859
873
  end
860
874
  local function getConnectionStatus()
861
875
  local conn = State.getActiveConnection()
@@ -906,11 +920,12 @@ local function ensureIdentityWatcher(conn)
906
920
  end
907
921
  end
908
922
  function sendReady(conn)
909
- if duplicateInstanceRole then
910
- return nil
911
- end
912
923
  local now = tick()
913
- if now - lastReadyPostAt < 2 then
924
+ -- Normal identity refreshes stay conservatively throttled. Once a stale
925
+ -- predecessor causes 409, retry once per second so takeover follows the
926
+ -- server's short inactivity lease without another two seconds of jitter.
927
+ local readyInterval = if retryingDuplicateReady then 1 else 2
928
+ if now - lastReadyPostAt < readyInterval then
914
929
  return nil
915
930
  end
916
931
  lastReadyPostAt = now
@@ -942,28 +957,39 @@ function sendReady(conn)
942
957
  local readyRole = detectRole()
943
958
  local readyLogKey = `{conn.serverUrl}|{instanceId}|{readyRole}`
944
959
  if not readyOk then
960
+ local shouldLog = not (readyFailureLogKeys[readyLogKey] ~= nil)
945
961
  readyFailureLogKeys[readyLogKey] = true
946
- warn(`[robloxstudio-mcp] /ready failed for {instanceId}/{readyRole}: {HttpDiagnostics.formatRequestFailure(readyUrl, readyOk, readyResult)}`)
962
+ if shouldLog then
963
+ warn(`[robloxstudio-mcp] /ready failed for {instanceId}/{readyRole}: {HttpDiagnostics.formatRequestFailure(readyUrl, readyOk, readyResult)}`)
964
+ end
947
965
  return nil
948
966
  end
949
967
  if not readyResult.Success then
950
968
  local reason = HttpDiagnostics.formatRequestFailure(readyUrl, true, readyResult)
969
+ local shouldLog = not (readyFailureLogKeys[readyLogKey] ~= nil)
951
970
  readyFailureLogKeys[readyLogKey] = true
952
- -- 409 = duplicate_instance_role. Surface in UI and stop polling.
971
+ -- A predecessor can remain registered briefly when Studio exits before
972
+ -- its asynchronous /disconnect completes. Keep polling and retrying
973
+ -- /ready: the server will take us over once the predecessor has stopped
974
+ -- polling, while a genuinely active duplicate continues to hold routing.
953
975
  if readyResult.StatusCode == 409 then
954
- duplicateInstanceRole = true
955
- conn.isActive = false
976
+ retryingDuplicateReady = true
956
977
  local ui = UI.getElements()
957
- ui.statusLabel.Text = "Duplicate instance"
958
- ui.statusLabel.TextColor3 = Color3.fromRGB(239, 68, 68)
978
+ ui.statusLabel.Text = "Waiting for previous instance"
979
+ ui.statusLabel.TextColor3 = Color3.fromRGB(245, 158, 11)
959
980
  ui.detailStatusLabel.Text = reason
960
- ui.detailStatusLabel.TextColor3 = Color3.fromRGB(239, 68, 68)
961
- warn(`[robloxstudio-mcp] /ready rejected for {instanceId}/{readyRole}: {reason}`)
981
+ ui.detailStatusLabel.TextColor3 = Color3.fromRGB(245, 158, 11)
982
+ if shouldLog then
983
+ warn(`[robloxstudio-mcp] /ready rejected for {instanceId}/{readyRole}: {reason}`)
984
+ end
962
985
  return nil
963
986
  end
964
- warn(`[robloxstudio-mcp] /ready rejected for {instanceId}/{readyRole}: {reason}`)
987
+ if shouldLog then
988
+ warn(`[robloxstudio-mcp] /ready rejected for {instanceId}/{readyRole}: {reason}`)
989
+ end
965
990
  return nil
966
991
  end
992
+ retryingDuplicateReady = false
967
993
  local parseOk, readyData = pcall(function()
968
994
  return HttpService:JSONDecode(readyResult.Body)
969
995
  end)
@@ -1413,9 +1439,9 @@ local function computeBridgeStamp()
1413
1439
  for i = 1, #combined do
1414
1440
  h = (h * 33 + (string.byte(combined, i))) % 2147483647
1415
1441
  end
1416
- -- "2.22.0" is replaced with the package version at package time
1442
+ -- "2.22.2" is replaced with the package version at package time
1417
1443
  -- (scripts/build-plugin.mjs injectVersion), so a release bump also restamps.
1418
- return `{tostring(h)}-2.22.0`
1444
+ return `{tostring(h)}-2.22.2`
1419
1445
  end
1420
1446
  local BRIDGE_STAMP = computeBridgeStamp()
1421
1447
  local function setSource(scriptInst, source)
@@ -10425,6 +10451,41 @@ end
10425
10451
  local function nowSec()
10426
10452
  return DateTime.now().UnixTimestampMillis / 1000
10427
10453
  end
10454
+ -- Studio occasionally exposes binary-bearing Output messages through
10455
+ -- LogService (for example, plugin hydration diagnostics containing raw CSG
10456
+ -- data). HttpService:JSONEncode rejects those strings outright. Preserve all
10457
+ -- valid UTF-8 verbatim and make only malformed bytes JSON-safe and visible.
10458
+ local function escapeInvalidUtf8(msg)
10459
+ local valid = utf8.len(msg)
10460
+ -- Roblox currently returns nil (not the false declared by @rbxts/types)
10461
+ -- when it encounters a malformed sequence. A numeric result is the only
10462
+ -- portable success discriminator across both representations.
10463
+ if type(valid) == "number" then
10464
+ return msg
10465
+ end
10466
+ local parts = {}
10467
+ local cursor = 1
10468
+ while cursor <= #msg do
10469
+ local suffixValid, invalidPosition = utf8.len(msg, cursor)
10470
+ if type(suffixValid) == "number" then
10471
+ local _arg0 = string.sub(msg, cursor)
10472
+ table.insert(parts, _arg0)
10473
+ break
10474
+ end
10475
+ if not (type(invalidPosition) == "number") then
10476
+ break
10477
+ end
10478
+ if invalidPosition > cursor then
10479
+ local _arg0 = string.sub(msg, cursor, invalidPosition - 1)
10480
+ table.insert(parts, _arg0)
10481
+ end
10482
+ local invalidByte = string.byte(msg, invalidPosition)
10483
+ local _arg0 = string.format("\\x%02X", invalidByte)
10484
+ table.insert(parts, _arg0)
10485
+ cursor = invalidPosition + 1
10486
+ end
10487
+ return table.concat(parts, "")
10488
+ end
10428
10489
  local function dropOldestUntilFits(incomingBytes)
10429
10490
  while #entries > 0 and (totalBytes + incomingBytes > MAX_BYTES or #entries >= HARD_ENTRY_CAP) do
10430
10491
  local dropped = table.remove(entries, 1)
@@ -10436,37 +10497,44 @@ local function pushEntry(msg, t, ts)
10436
10497
  if ts == nil then
10437
10498
  ts = nowSec()
10438
10499
  end
10439
- local bytes = #msg
10500
+ local safeMessage = escapeInvalidUtf8(msg)
10501
+ local bytes = #safeMessage
10440
10502
  dropOldestUntilFits(bytes)
10441
10503
  local _arg0 = {
10442
10504
  seq = nextSeq,
10443
10505
  ts = ts,
10444
10506
  level = levelTag(t),
10445
- message = msg,
10507
+ message = safeMessage,
10446
10508
  }
10447
10509
  table.insert(entries, _arg0)
10448
10510
  nextSeq += 1
10449
10511
  totalBytes += bytes
10450
10512
  end
10451
10513
  local function seedRuntimeHistory()
10452
- if not RunService:IsRunning() then
10453
- return nil
10454
- end
10455
10514
  local ok, history = pcall(function()
10456
10515
  return LogService:GetLogHistory()
10457
10516
  end)
10458
10517
  if not ok then
10459
10518
  return nil
10460
10519
  end
10520
+ local isEdit = not RunService:IsRunning()
10521
+ -- GetLogHistory timestamps and DateTime.now() share Unix time, while
10522
+ -- os.clock() is elapsed time for this Studio process. Their difference is
10523
+ -- therefore the process launch boundary. Edit-mode history is filtered to
10524
+ -- that boundary so startup errors from this launch are recovered without
10525
+ -- importing history left by an earlier Studio process.
10526
+ local processStartedAt = nowSec() - os.clock()
10461
10527
  for _, entry in history do
10462
10528
  local _message = entry.message
10463
10529
  if not (type(_message) == "string") then
10464
10530
  continue
10465
10531
  end
10466
- local _exp = entry.message
10467
- local _exp_1 = entry.messageType
10468
10532
  local _timestamp = entry.timestamp
10469
- pushEntry(_exp, _exp_1, if type(_timestamp) == "number" then entry.timestamp else nil)
10533
+ local timestamp = if type(_timestamp) == "number" then entry.timestamp else nil
10534
+ if isEdit and (timestamp == nil or timestamp < processStartedAt - 1) then
10535
+ continue
10536
+ end
10537
+ pushEntry(entry.message, entry.messageType, timestamp)
10470
10538
  end
10471
10539
  end
10472
10540
  local function install()
@@ -10477,9 +10545,9 @@ local function install()
10477
10545
  return nil
10478
10546
  end
10479
10547
  installed = true
10480
- -- Play peers can emit startup logs before the plugin finishes loading.
10481
- -- Seed from per-DataModel LogHistory so get_runtime_logs can still see
10482
- -- those early messages; skip edit mode to avoid stale prior-session logs.
10548
+ -- Every peer can emit startup logs before the plugin finishes loading.
10549
+ -- Seed from per-DataModel LogHistory so get_runtime_logs can still see them;
10550
+ -- edit history is bounded to the current Studio process above.
10483
10551
  seedRuntimeHistory()
10484
10552
  LogService.MessageOut:Connect(function(msg, t)
10485
10553
  pushEntry(msg, t)
@@ -10745,7 +10813,7 @@ return {
10745
10813
  <Properties>
10746
10814
  <string name="Name">State</string>
10747
10815
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
10748
- local CURRENT_VERSION = "2.22.0"
10816
+ local CURRENT_VERSION = "2.22.2"
10749
10817
  local PLUGIN_VARIANT = "main"
10750
10818
  local BASE_PORT = 58741
10751
10819
  local function createConnection(port)
@@ -53,11 +53,11 @@ function computeInstanceId(): string {
53
53
  }
54
54
 
55
55
  let assignedRole: string | undefined;
56
- let duplicateInstanceRole = false;
57
56
  let hasVersionMismatch = false;
58
57
  let lastVersionMismatchWarningKey: string | undefined;
59
58
  let lastReadyInstanceId: string | undefined;
60
59
  const readyFailureLogKeys = new Set<string>();
60
+ let retryingDuplicateReady = false;
61
61
 
62
62
  // Cache the published place name from MarketplaceService:GetProductInfo so
63
63
  // /ready can carry a friendly identifier (e.g. "Natural Disasters") distinct
@@ -197,14 +197,27 @@ function processRequest(request: RequestPayload): unknown {
197
197
  }
198
198
 
199
199
  function sendResponse(conn: Connection, requestId: string, responseData: unknown) {
200
- pcall(() => {
201
- HttpService.RequestAsync({
202
- Url: `${conn.serverUrl}/response`,
203
- Method: "POST",
204
- Headers: { "Content-Type": "application/json" },
205
- Body: HttpService.JSONEncode({ requestId, response: responseData }),
200
+ const responseUrl = `${conn.serverUrl}/response`;
201
+ const [encodeOk, encoded] = pcall(() => HttpService.JSONEncode({ requestId, response: responseData }));
202
+ const body = encodeOk
203
+ ? encoded
204
+ : HttpService.JSONEncode({
205
+ requestId,
206
+ error: `Plugin response serialization failed: ${tostring(encoded)}`,
206
207
  });
207
- });
208
+ if (!encodeOk) {
209
+ warn(`[robloxstudio-mcp] Failed to serialize response ${requestId}: ${tostring(encoded)}`);
210
+ }
211
+
212
+ const [requestOk, requestResult] = pcall(() => HttpService.RequestAsync({
213
+ Url: responseUrl,
214
+ Method: "POST",
215
+ Headers: { "Content-Type": "application/json" },
216
+ Body: body,
217
+ }));
218
+ if (!requestOk || !requestResult.Success) {
219
+ warn(`[robloxstudio-mcp] Failed to deliver response ${requestId}: ${HttpDiagnostics.formatRequestFailure(responseUrl, requestOk, requestResult)}`);
220
+ }
208
221
  }
209
222
 
210
223
  function getConnectionStatus(): string {
@@ -248,9 +261,12 @@ function ensureIdentityWatcher(conn: Connection): void {
248
261
  }
249
262
 
250
263
  function sendReady(conn: Connection): void {
251
- if (duplicateInstanceRole) return; // stop retrying once the server has rejected us
252
264
  const now = tick();
253
- if (now - lastReadyPostAt < 2) return; // throttle to ≤1 /ready every 2s
265
+ // Normal identity refreshes stay conservatively throttled. Once a stale
266
+ // predecessor causes 409, retry once per second so takeover follows the
267
+ // server's short inactivity lease without another two seconds of jitter.
268
+ const readyInterval = retryingDuplicateReady ? 1 : 2;
269
+ if (now - lastReadyPostAt < readyInterval) return;
254
270
  lastReadyPostAt = now;
255
271
  const instanceId = computeInstanceId();
256
272
  task.spawn(() => {
@@ -278,28 +294,39 @@ function sendReady(conn: Connection): void {
278
294
  const readyRole = detectRole();
279
295
  const readyLogKey = `${conn.serverUrl}|${instanceId}|${readyRole}`;
280
296
  if (!readyOk) {
297
+ const shouldLog = !readyFailureLogKeys.has(readyLogKey);
281
298
  readyFailureLogKeys.add(readyLogKey);
282
- warn(`[robloxstudio-mcp] /ready failed for ${instanceId}/${readyRole}: ${HttpDiagnostics.formatRequestFailure(readyUrl, readyOk, readyResult)}`);
299
+ if (shouldLog) {
300
+ warn(`[robloxstudio-mcp] /ready failed for ${instanceId}/${readyRole}: ${HttpDiagnostics.formatRequestFailure(readyUrl, readyOk, readyResult)}`);
301
+ }
283
302
  return;
284
303
  }
285
304
  if (!readyResult.Success) {
286
305
  const reason = HttpDiagnostics.formatRequestFailure(readyUrl, true, readyResult);
306
+ const shouldLog = !readyFailureLogKeys.has(readyLogKey);
287
307
  readyFailureLogKeys.add(readyLogKey);
288
- // 409 = duplicate_instance_role. Surface in UI and stop polling.
308
+ // A predecessor can remain registered briefly when Studio exits before
309
+ // its asynchronous /disconnect completes. Keep polling and retrying
310
+ // /ready: the server will take us over once the predecessor has stopped
311
+ // polling, while a genuinely active duplicate continues to hold routing.
289
312
  if (readyResult.StatusCode === 409) {
290
- duplicateInstanceRole = true;
291
- conn.isActive = false;
313
+ retryingDuplicateReady = true;
292
314
  const ui = UI.getElements();
293
- ui.statusLabel.Text = "Duplicate instance";
294
- ui.statusLabel.TextColor3 = Color3.fromRGB(239, 68, 68);
315
+ ui.statusLabel.Text = "Waiting for previous instance";
316
+ ui.statusLabel.TextColor3 = Color3.fromRGB(245, 158, 11);
295
317
  ui.detailStatusLabel.Text = reason;
296
- ui.detailStatusLabel.TextColor3 = Color3.fromRGB(239, 68, 68);
297
- warn(`[robloxstudio-mcp] /ready rejected for ${instanceId}/${readyRole}: ${reason}`);
318
+ ui.detailStatusLabel.TextColor3 = Color3.fromRGB(245, 158, 11);
319
+ if (shouldLog) {
320
+ warn(`[robloxstudio-mcp] /ready rejected for ${instanceId}/${readyRole}: ${reason}`);
321
+ }
298
322
  return;
299
323
  }
300
- warn(`[robloxstudio-mcp] /ready rejected for ${instanceId}/${readyRole}: ${reason}`);
324
+ if (shouldLog) {
325
+ warn(`[robloxstudio-mcp] /ready rejected for ${instanceId}/${readyRole}: ${reason}`);
326
+ }
301
327
  return;
302
328
  }
329
+ retryingDuplicateReady = false;
303
330
  const [parseOk, readyData] = pcall(
304
331
  () => HttpService.JSONDecode(readyResult.Body) as ReadyResponse,
305
332
  );
@@ -46,6 +46,37 @@ function nowSec(): number {
46
46
  return DateTime.now().UnixTimestampMillis / 1000;
47
47
  }
48
48
 
49
+ // Studio occasionally exposes binary-bearing Output messages through
50
+ // LogService (for example, plugin hydration diagnostics containing raw CSG
51
+ // data). HttpService:JSONEncode rejects those strings outright. Preserve all
52
+ // valid UTF-8 verbatim and make only malformed bytes JSON-safe and visible.
53
+ function escapeInvalidUtf8(msg: string): string {
54
+ const [valid] = utf8.len(msg);
55
+ // Roblox currently returns nil (not the false declared by @rbxts/types)
56
+ // when it encounters a malformed sequence. A numeric result is the only
57
+ // portable success discriminator across both representations.
58
+ if (typeIs(valid, "number")) return msg;
59
+
60
+ const parts: string[] = [];
61
+ let cursor = 1;
62
+ while (cursor <= msg.size()) {
63
+ const [suffixValid, invalidPosition] = utf8.len(msg, cursor);
64
+ if (typeIs(suffixValid, "number")) {
65
+ parts.push(string.sub(msg, cursor));
66
+ break;
67
+ }
68
+ if (!typeIs(invalidPosition, "number")) break;
69
+
70
+ if (invalidPosition > cursor) {
71
+ parts.push(string.sub(msg, cursor, invalidPosition - 1));
72
+ }
73
+ const [invalidByte] = string.byte(msg, invalidPosition);
74
+ parts.push(string.format("\\x%02X", invalidByte));
75
+ cursor = invalidPosition + 1;
76
+ }
77
+ return parts.join("");
78
+ }
79
+
49
80
  function dropOldestUntilFits(incomingBytes: number): void {
50
81
  while (
51
82
  entries.size() > 0 &&
@@ -58,13 +89,14 @@ function dropOldestUntilFits(incomingBytes: number): void {
58
89
  }
59
90
 
60
91
  function pushEntry(msg: string, t: Enum.MessageType, ts = nowSec()): void {
61
- const bytes = msg.size();
92
+ const safeMessage = escapeInvalidUtf8(msg);
93
+ const bytes = safeMessage.size();
62
94
  dropOldestUntilFits(bytes);
63
95
  entries.push({
64
96
  seq: nextSeq,
65
97
  ts,
66
98
  level: levelTag(t),
67
- message: msg,
99
+ message: safeMessage,
68
100
  });
69
101
  nextSeq += 1;
70
102
  totalBytes += bytes;
@@ -77,14 +109,21 @@ interface LogHistoryEntry {
77
109
  }
78
110
 
79
111
  function seedRuntimeHistory(): void {
80
- if (!RunService.IsRunning()) return;
81
-
82
112
  const [ok, history] = pcall(() => LogService.GetLogHistory() as LogHistoryEntry[]);
83
113
  if (!ok) return;
114
+ const isEdit = !RunService.IsRunning();
115
+ // GetLogHistory timestamps and DateTime.now() share Unix time, while
116
+ // os.clock() is elapsed time for this Studio process. Their difference is
117
+ // therefore the process launch boundary. Edit-mode history is filtered to
118
+ // that boundary so startup errors from this launch are recovered without
119
+ // importing history left by an earlier Studio process.
120
+ const processStartedAt = nowSec() - os.clock();
84
121
 
85
122
  for (const entry of history) {
86
123
  if (!typeIs(entry.message, "string")) continue;
87
- pushEntry(entry.message, entry.messageType, typeIs(entry.timestamp, "number") ? entry.timestamp : undefined);
124
+ const timestamp = typeIs(entry.timestamp, "number") ? entry.timestamp : undefined;
125
+ if (isEdit && (timestamp === undefined || timestamp < processStartedAt - 1)) continue;
126
+ pushEntry(entry.message, entry.messageType, timestamp);
88
127
  }
89
128
  }
90
129
 
@@ -92,9 +131,9 @@ function install(): void {
92
131
  if (installed) return;
93
132
  if (!RunService.IsStudio()) return;
94
133
  installed = true;
95
- // Play peers can emit startup logs before the plugin finishes loading.
96
- // Seed from per-DataModel LogHistory so get_runtime_logs can still see
97
- // those early messages; skip edit mode to avoid stale prior-session logs.
134
+ // Every peer can emit startup logs before the plugin finishes loading.
135
+ // Seed from per-DataModel LogHistory so get_runtime_logs can still see them;
136
+ // edit history is bounded to the current Studio process above.
98
137
  seedRuntimeHistory();
99
138
  LogService.MessageOut.Connect((msg, t) => {
100
139
  pushEntry(msg, t);