@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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
|
|
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
|
@@ -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
|
-
|
|
847
|
-
|
|
848
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
--
|
|
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
|
-
|
|
955
|
-
conn.isActive = false
|
|
976
|
+
retryingDuplicateReady = true
|
|
956
977
|
local ui = UI.getElements()
|
|
957
|
-
ui.statusLabel.Text = "
|
|
958
|
-
ui.statusLabel.TextColor3 = Color3.fromRGB(
|
|
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(
|
|
961
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
-
--
|
|
10481
|
-
-- Seed from per-DataModel LogHistory so get_runtime_logs can still see
|
|
10482
|
-
--
|
|
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.
|
|
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
|
-
|
|
847
|
-
|
|
848
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
--
|
|
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
|
-
|
|
955
|
-
conn.isActive = false
|
|
976
|
+
retryingDuplicateReady = true
|
|
956
977
|
local ui = UI.getElements()
|
|
957
|
-
ui.statusLabel.Text = "
|
|
958
|
-
ui.statusLabel.TextColor3 = Color3.fromRGB(
|
|
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(
|
|
961
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
-
--
|
|
10481
|
-
-- Seed from per-DataModel LogHistory so get_runtime_logs can still see
|
|
10482
|
-
--
|
|
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.
|
|
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
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
291
|
-
conn.isActive = false;
|
|
313
|
+
retryingDuplicateReady = true;
|
|
292
314
|
const ui = UI.getElements();
|
|
293
|
-
ui.statusLabel.Text = "
|
|
294
|
-
ui.statusLabel.TextColor3 = Color3.fromRGB(
|
|
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(
|
|
297
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
-
//
|
|
96
|
-
// Seed from per-DataModel LogHistory so get_runtime_logs can still see
|
|
97
|
-
//
|
|
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);
|