@chrrxs/robloxstudio-mcp-inspector 2.19.0 → 2.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +1208 -297
- package/package.json +1 -1
- package/studio-plugin/MCPInspectorPlugin.rbxmx +226 -120
- package/studio-plugin/MCPPlugin.rbxmx +226 -120
- package/studio-plugin/src/modules/ServerUrlSettings.ts +6 -3
- package/studio-plugin/src/modules/Utils.ts +17 -0
- package/studio-plugin/src/modules/handlers/QueryHandlers.ts +68 -3
- package/studio-plugin/src/modules/handlers/ScriptHandlers.ts +47 -50
- package/studio-plugin/src/modules/handlers/TestHandlers.ts +6 -13
- package/studio-plugin/src/server/index.server.ts +2 -0
package/dist/index.js
CHANGED
|
@@ -196,6 +196,9 @@ var init_bridge_service = __esm({
|
|
|
196
196
|
}
|
|
197
197
|
return removed;
|
|
198
198
|
}
|
|
199
|
+
async unregisterInstanceIdEverywhere(instanceId) {
|
|
200
|
+
return this.unregisterInstanceId(instanceId);
|
|
201
|
+
}
|
|
199
202
|
getInstances() {
|
|
200
203
|
return Array.from(this.instances.values());
|
|
201
204
|
}
|
|
@@ -451,13 +454,260 @@ var init_bridge_service = __esm({
|
|
|
451
454
|
}
|
|
452
455
|
});
|
|
453
456
|
|
|
457
|
+
// ../core/dist/roblox-docs.js
|
|
458
|
+
function isDocCategory(value) {
|
|
459
|
+
return DOC_CATEGORIES.includes(value);
|
|
460
|
+
}
|
|
461
|
+
function cacheGet(key) {
|
|
462
|
+
const entry = cache.get(key);
|
|
463
|
+
if (!entry)
|
|
464
|
+
return void 0;
|
|
465
|
+
const ttl = entry.notFound ? NEGATIVE_CACHE_TTL_MS : CACHE_TTL_MS;
|
|
466
|
+
if (Date.now() - entry.fetchedAt > ttl) {
|
|
467
|
+
cache.delete(key);
|
|
468
|
+
return void 0;
|
|
469
|
+
}
|
|
470
|
+
return entry;
|
|
471
|
+
}
|
|
472
|
+
function cacheSet(key, entry) {
|
|
473
|
+
if (cache.size >= MAX_CACHE_ENTRIES) {
|
|
474
|
+
const oldest = cache.keys().next().value;
|
|
475
|
+
if (oldest !== void 0)
|
|
476
|
+
cache.delete(oldest);
|
|
477
|
+
}
|
|
478
|
+
cache.set(key, entry);
|
|
479
|
+
}
|
|
480
|
+
function docUrl(category, name) {
|
|
481
|
+
return `${DOCS_BASE_URL}/${category}/${encodeURIComponent(name)}.md`;
|
|
482
|
+
}
|
|
483
|
+
async function fetchRobloxDoc(category, name) {
|
|
484
|
+
const key = `${category}/${name}`;
|
|
485
|
+
const cached = cacheGet(key);
|
|
486
|
+
if (cached) {
|
|
487
|
+
if (cached.notFound)
|
|
488
|
+
throw new DocNotFoundError(category, name);
|
|
489
|
+
return cached.content;
|
|
490
|
+
}
|
|
491
|
+
const controller = new AbortController();
|
|
492
|
+
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
493
|
+
let response;
|
|
494
|
+
try {
|
|
495
|
+
response = await fetch(docUrl(category, name), {
|
|
496
|
+
signal: controller.signal,
|
|
497
|
+
headers: { Accept: "text/markdown, text/plain" }
|
|
498
|
+
});
|
|
499
|
+
} catch (error) {
|
|
500
|
+
throw new Error(`Failed to fetch Roblox docs for ${key}: ${error instanceof Error ? error.message : String(error)}`);
|
|
501
|
+
} finally {
|
|
502
|
+
clearTimeout(timer);
|
|
503
|
+
}
|
|
504
|
+
if (response.status === 404) {
|
|
505
|
+
cacheSet(key, { fetchedAt: Date.now(), notFound: true });
|
|
506
|
+
throw new DocNotFoundError(category, name);
|
|
507
|
+
}
|
|
508
|
+
if (!response.ok) {
|
|
509
|
+
throw new Error(`Failed to fetch Roblox docs for ${key}: HTTP ${response.status}`);
|
|
510
|
+
}
|
|
511
|
+
const content = await response.text();
|
|
512
|
+
cacheSet(key, { fetchedAt: Date.now(), content });
|
|
513
|
+
return content;
|
|
514
|
+
}
|
|
515
|
+
function listSections(markdown) {
|
|
516
|
+
const sections = [];
|
|
517
|
+
for (const line of markdown.split("\n")) {
|
|
518
|
+
const match = line.match(/^##\s+(.+?)\s*$/);
|
|
519
|
+
if (match)
|
|
520
|
+
sections.push(match[1]);
|
|
521
|
+
}
|
|
522
|
+
return sections;
|
|
523
|
+
}
|
|
524
|
+
function extractSection(markdown, section) {
|
|
525
|
+
const lines = markdown.split("\n");
|
|
526
|
+
const wanted = section.trim().toLowerCase();
|
|
527
|
+
let start = -1;
|
|
528
|
+
for (let i = 0; i < lines.length; i++) {
|
|
529
|
+
const match = lines[i].match(/^##\s+(.+?)\s*$/);
|
|
530
|
+
if (match && match[1].toLowerCase() === wanted) {
|
|
531
|
+
start = i;
|
|
532
|
+
break;
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
if (start === -1)
|
|
536
|
+
return void 0;
|
|
537
|
+
let end = lines.length;
|
|
538
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
539
|
+
if (/^##?\s+/.test(lines[i])) {
|
|
540
|
+
end = i;
|
|
541
|
+
break;
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
return lines.slice(start, end).join("\n").trimEnd();
|
|
545
|
+
}
|
|
546
|
+
async function getRobloxDoc(category, name, section) {
|
|
547
|
+
const markdown = await fetchRobloxDoc(category, name);
|
|
548
|
+
const sections = listSections(markdown);
|
|
549
|
+
if (section) {
|
|
550
|
+
const extracted = extractSection(markdown, section);
|
|
551
|
+
if (extracted === void 0) {
|
|
552
|
+
throw new Error(`Section "${section}" not found in ${category}/${name}. Available sections: ${sections.join(", ") || "(none)"}`);
|
|
553
|
+
}
|
|
554
|
+
return { content: extracted, truncated: false, sections };
|
|
555
|
+
}
|
|
556
|
+
if (markdown.length > MAX_DOC_CHARS) {
|
|
557
|
+
const head = markdown.slice(0, MAX_DOC_CHARS);
|
|
558
|
+
const note = `
|
|
559
|
+
|
|
560
|
+
---
|
|
561
|
+
[Truncated at ${MAX_DOC_CHARS} of ${markdown.length} characters. Re-request with the "section" parameter to read one section in full. Available sections: ${sections.join(", ")}]`;
|
|
562
|
+
return { content: head + note, truncated: true, sections };
|
|
563
|
+
}
|
|
564
|
+
return { content: markdown, truncated: false, sections };
|
|
565
|
+
}
|
|
566
|
+
var DOC_CATEGORIES, DOCS_BASE_URL, FETCH_TIMEOUT_MS, CACHE_TTL_MS, NEGATIVE_CACHE_TTL_MS, MAX_CACHE_ENTRIES, MAX_DOC_CHARS, cache, DocNotFoundError;
|
|
567
|
+
var init_roblox_docs = __esm({
|
|
568
|
+
"../core/dist/roblox-docs.js"() {
|
|
569
|
+
"use strict";
|
|
570
|
+
DOC_CATEGORIES = ["classes", "enums", "datatypes", "libraries", "globals"];
|
|
571
|
+
DOCS_BASE_URL = "https://create.roblox.com/docs/reference/engine";
|
|
572
|
+
FETCH_TIMEOUT_MS = 15e3;
|
|
573
|
+
CACHE_TTL_MS = 24 * 60 * 60 * 1e3;
|
|
574
|
+
NEGATIVE_CACHE_TTL_MS = 5 * 60 * 1e3;
|
|
575
|
+
MAX_CACHE_ENTRIES = 200;
|
|
576
|
+
MAX_DOC_CHARS = 5e4;
|
|
577
|
+
cache = /* @__PURE__ */ new Map();
|
|
578
|
+
DocNotFoundError = class extends Error {
|
|
579
|
+
constructor(category, name) {
|
|
580
|
+
super(`No Roblox documentation found for ${category}/${name}. Names are case-sensitive PascalCase (e.g. "ProximityPrompt", "TweenService"). Valid categories: ${DOC_CATEGORIES.join(", ")}.`);
|
|
581
|
+
this.name = "DocNotFoundError";
|
|
582
|
+
}
|
|
583
|
+
};
|
|
584
|
+
}
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
// ../core/dist/mcp-compat.js
|
|
588
|
+
import { ErrorCode, ListResourceTemplatesRequestSchema, ListResourcesRequestSchema, McpError, ReadResourceRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
589
|
+
function registerResourceHandlers(server) {
|
|
590
|
+
server.registerCapabilities({ resources: {} });
|
|
591
|
+
server.setRequestHandler(ListResourcesRequestSchema, async () => ({
|
|
592
|
+
resources: []
|
|
593
|
+
}));
|
|
594
|
+
server.setRequestHandler(ListResourceTemplatesRequestSchema, async () => ({
|
|
595
|
+
resourceTemplates: [
|
|
596
|
+
{
|
|
597
|
+
uriTemplate: "robloxdocs://classes/{className}",
|
|
598
|
+
name: "Roblox class documentation",
|
|
599
|
+
description: "Official engine reference for a class (e.g. robloxdocs://classes/ProximityPrompt) \u2014 properties, methods, events, and usage guidance as markdown.",
|
|
600
|
+
mimeType: "text/markdown"
|
|
601
|
+
},
|
|
602
|
+
{
|
|
603
|
+
uriTemplate: "robloxdocs://enums/{enumName}",
|
|
604
|
+
name: "Roblox enum documentation",
|
|
605
|
+
description: "Official engine reference for an enum (e.g. robloxdocs://enums/KeyCode).",
|
|
606
|
+
mimeType: "text/markdown"
|
|
607
|
+
},
|
|
608
|
+
{
|
|
609
|
+
uriTemplate: "robloxdocs://datatypes/{dataTypeName}",
|
|
610
|
+
name: "Roblox datatype documentation",
|
|
611
|
+
description: "Official engine reference for a datatype (e.g. robloxdocs://datatypes/CFrame).",
|
|
612
|
+
mimeType: "text/markdown"
|
|
613
|
+
},
|
|
614
|
+
{
|
|
615
|
+
uriTemplate: "robloxdocs://libraries/{libraryName}",
|
|
616
|
+
name: "Roblox library documentation",
|
|
617
|
+
description: "Official engine reference for a Luau library (e.g. robloxdocs://libraries/table).",
|
|
618
|
+
mimeType: "text/markdown"
|
|
619
|
+
},
|
|
620
|
+
{
|
|
621
|
+
uriTemplate: "robloxdocs://globals/{globalsPage}",
|
|
622
|
+
name: "Roblox globals documentation",
|
|
623
|
+
description: "Official engine reference for global functions and variables (e.g. robloxdocs://globals/LuaGlobals, robloxdocs://globals/RobloxGlobals).",
|
|
624
|
+
mimeType: "text/markdown"
|
|
625
|
+
}
|
|
626
|
+
]
|
|
627
|
+
}));
|
|
628
|
+
server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
|
|
629
|
+
const uri = request.params.uri;
|
|
630
|
+
const match = uri.match(/^robloxdocs:\/\/([^/]+)\/([^/]+)$/);
|
|
631
|
+
if (!match || !isDocCategory(match[1])) {
|
|
632
|
+
throw new McpError(ErrorCode.InvalidParams, `Resource ${uri} not found`);
|
|
633
|
+
}
|
|
634
|
+
const [, category, rawName] = match;
|
|
635
|
+
const name = decodeURIComponent(rawName);
|
|
636
|
+
try {
|
|
637
|
+
const content = await fetchRobloxDoc(category, name);
|
|
638
|
+
return {
|
|
639
|
+
contents: [{ uri, mimeType: "text/markdown", text: content }]
|
|
640
|
+
};
|
|
641
|
+
} catch (error) {
|
|
642
|
+
if (error instanceof DocNotFoundError) {
|
|
643
|
+
throw new McpError(ErrorCode.InvalidParams, `Resource ${uri} not found. Names are case-sensitive PascalCase; valid categories: ${DOC_CATEGORIES.join(", ")}.`);
|
|
644
|
+
}
|
|
645
|
+
throw new McpError(ErrorCode.InternalError, `Failed to read ${uri}: ${error instanceof Error ? error.message : String(error)}`);
|
|
646
|
+
}
|
|
647
|
+
});
|
|
648
|
+
}
|
|
649
|
+
var init_mcp_compat = __esm({
|
|
650
|
+
"../core/dist/mcp-compat.js"() {
|
|
651
|
+
"use strict";
|
|
652
|
+
init_roblox_docs();
|
|
653
|
+
}
|
|
654
|
+
});
|
|
655
|
+
|
|
454
656
|
// ../core/dist/http-server.js
|
|
455
657
|
import express from "express";
|
|
456
658
|
import cors from "cors";
|
|
457
659
|
import http from "http";
|
|
458
660
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
459
661
|
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
460
|
-
import { CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, McpError } from "@modelcontextprotocol/sdk/types.js";
|
|
662
|
+
import { CallToolRequestSchema, ErrorCode as ErrorCode2, ListToolsRequestSchema, McpError as McpError2 } from "@modelcontextprotocol/sdk/types.js";
|
|
663
|
+
function parseLineRange(lineRange) {
|
|
664
|
+
const validLine = (line) => line === void 0 || line >= 1;
|
|
665
|
+
if (typeof lineRange === "string") {
|
|
666
|
+
const ranged = lineRange.match(/^\s*(\d+)?\s*[-:]\s*(\d+)?\s*$/);
|
|
667
|
+
if (ranged) {
|
|
668
|
+
const s = ranged[1] !== void 0 ? parseInt(ranged[1], 10) : void 0;
|
|
669
|
+
const e = ranged[2] !== void 0 ? parseInt(ranged[2], 10) : void 0;
|
|
670
|
+
if (!validLine(s) || !validLine(e))
|
|
671
|
+
return void 0;
|
|
672
|
+
if (s !== void 0 && e !== void 0 && s > e)
|
|
673
|
+
return void 0;
|
|
674
|
+
if (s !== void 0 || e !== void 0)
|
|
675
|
+
return { startLine: s, endLine: e };
|
|
676
|
+
}
|
|
677
|
+
const single = lineRange.match(/^\s*(\d+)\s*$/);
|
|
678
|
+
if (single) {
|
|
679
|
+
const n = parseInt(single[1], 10);
|
|
680
|
+
if (n < 1)
|
|
681
|
+
return void 0;
|
|
682
|
+
return { startLine: n, endLine: n };
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
return void 0;
|
|
686
|
+
}
|
|
687
|
+
function optionalLineRange(body, toolName) {
|
|
688
|
+
if (body.line_range === void 0)
|
|
689
|
+
return {};
|
|
690
|
+
const parsed = parseLineRange(body.line_range);
|
|
691
|
+
if (!parsed)
|
|
692
|
+
throw new Error(`${toolName} line_range must be a string like "42", "10-20", "10-", or "-20"`);
|
|
693
|
+
return parsed;
|
|
694
|
+
}
|
|
695
|
+
function optionalLineAnchor(body, toolName) {
|
|
696
|
+
const parsed = optionalLineRange(body, toolName);
|
|
697
|
+
if (parsed.startLine === void 0 && parsed.endLine === void 0)
|
|
698
|
+
return void 0;
|
|
699
|
+
if (parsed.startLine === void 0 || parsed.endLine === void 0 || parsed.endLine !== parsed.startLine) {
|
|
700
|
+
throw new Error(`${toolName} line_range must be a single line like "42"`);
|
|
701
|
+
}
|
|
702
|
+
return parsed.startLine;
|
|
703
|
+
}
|
|
704
|
+
function requiredClosedLineRange(body, toolName) {
|
|
705
|
+
const parsed = optionalLineRange(body, toolName);
|
|
706
|
+
if (parsed.startLine === void 0 || parsed.endLine === void 0) {
|
|
707
|
+
throw new Error(`${toolName} requires line_range as "start-end" or a single line like "42"`);
|
|
708
|
+
}
|
|
709
|
+
return { startLine: parsed.startLine, endLine: parsed.endLine };
|
|
710
|
+
}
|
|
461
711
|
function createHttpServer(tools, bridge, allowedTools, serverConfig) {
|
|
462
712
|
const app = express();
|
|
463
713
|
let mcpServerActive = false;
|
|
@@ -590,6 +840,15 @@ function createHttpServer(tools, bridge, allowedTools, serverConfig) {
|
|
|
590
840
|
}
|
|
591
841
|
res.json({ success: true });
|
|
592
842
|
});
|
|
843
|
+
app.post("/unregister-instance-id", (req, res) => {
|
|
844
|
+
const { instanceId } = req.body;
|
|
845
|
+
if (typeof instanceId !== "string" || instanceId.length === 0) {
|
|
846
|
+
res.status(400).json({ error: "instanceId is required" });
|
|
847
|
+
return;
|
|
848
|
+
}
|
|
849
|
+
const removed = bridge.unregisterInstanceId(instanceId);
|
|
850
|
+
res.json({ success: true, removed });
|
|
851
|
+
});
|
|
593
852
|
app.get("/status", (req, res) => {
|
|
594
853
|
const instances = bridge.getInstances();
|
|
595
854
|
const publicInstances = instances.map(toPublic);
|
|
@@ -707,6 +966,7 @@ function createHttpServer(tools, bridge, allowedTools, serverConfig) {
|
|
|
707
966
|
try {
|
|
708
967
|
trackMCPActivity();
|
|
709
968
|
const server = new Server({ name: serverConfig.name, version: serverConfig.version }, { capabilities: { tools: {} } });
|
|
969
|
+
registerResourceHandlers(server);
|
|
710
970
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
711
971
|
tools: filteredTools.map((t) => ({
|
|
712
972
|
name: t.name,
|
|
@@ -717,11 +977,11 @@ function createHttpServer(tools, bridge, allowedTools, serverConfig) {
|
|
|
717
977
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
718
978
|
const { name, arguments: args } = request.params;
|
|
719
979
|
if (allowedTools && !allowedTools.has(name)) {
|
|
720
|
-
throw new
|
|
980
|
+
throw new McpError2(ErrorCode2.MethodNotFound, `Unknown tool: ${name}`);
|
|
721
981
|
}
|
|
722
982
|
const handler = TOOL_HANDLERS[name];
|
|
723
983
|
if (!handler) {
|
|
724
|
-
throw new
|
|
984
|
+
throw new McpError2(ErrorCode2.MethodNotFound, `Unknown tool: ${name}`);
|
|
725
985
|
}
|
|
726
986
|
try {
|
|
727
987
|
return await handler(tools, args || {});
|
|
@@ -739,9 +999,9 @@ function createHttpServer(tools, bridge, allowedTools, serverConfig) {
|
|
|
739
999
|
isError: true
|
|
740
1000
|
};
|
|
741
1001
|
}
|
|
742
|
-
if (error instanceof
|
|
1002
|
+
if (error instanceof McpError2)
|
|
743
1003
|
throw error;
|
|
744
|
-
throw new
|
|
1004
|
+
throw new McpError2(ErrorCode2.InternalError, `Tool execution failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
745
1005
|
}
|
|
746
1006
|
});
|
|
747
1007
|
const transport = new StreamableHTTPServerTransport({
|
|
@@ -847,7 +1107,9 @@ var init_http_server = __esm({
|
|
|
847
1107
|
"../core/dist/http-server.js"() {
|
|
848
1108
|
"use strict";
|
|
849
1109
|
init_bridge_service();
|
|
1110
|
+
init_mcp_compat();
|
|
850
1111
|
TOOL_HANDLERS = {
|
|
1112
|
+
get_roblox_docs: (tools, body) => tools.getRobloxDocs(body.name, body.doc_type, body.section),
|
|
851
1113
|
get_file_tree: (tools, body) => tools.getFileTree(body.path, body.instance_id),
|
|
852
1114
|
search_files: (tools, body) => tools.searchFiles(body.query, body.searchType, body.instance_id),
|
|
853
1115
|
get_place_info: (tools, body) => tools.getPlaceInfo(body.instance_id),
|
|
@@ -877,11 +1139,17 @@ var init_http_server = __esm({
|
|
|
877
1139
|
path: body.path,
|
|
878
1140
|
classFilter: body.classFilter
|
|
879
1141
|
}, body.instance_id),
|
|
880
|
-
get_script_source: (tools, body) =>
|
|
1142
|
+
get_script_source: (tools, body) => {
|
|
1143
|
+
const { startLine, endLine } = optionalLineRange(body, "get_script_source");
|
|
1144
|
+
return tools.getScriptSource(body.instancePath, startLine, endLine, body.instance_id);
|
|
1145
|
+
},
|
|
881
1146
|
set_script_source: (tools, body) => tools.setScriptSource(body.instancePath, body.source, body.instance_id),
|
|
882
|
-
edit_script_lines: (tools, body) => tools.editScriptLines(body.instancePath, body.old_string, body.new_string, body
|
|
1147
|
+
edit_script_lines: (tools, body) => tools.editScriptLines(body.instancePath, body.old_string, body.new_string, optionalLineAnchor(body, "edit_script_lines"), body.instance_id),
|
|
883
1148
|
insert_script_lines: (tools, body) => tools.insertScriptLines(body.instancePath, body.afterLine, body.newContent, body.instance_id),
|
|
884
|
-
delete_script_lines: (tools, body) =>
|
|
1149
|
+
delete_script_lines: (tools, body) => {
|
|
1150
|
+
const { startLine, endLine } = requiredClosedLineRange(body, "delete_script_lines");
|
|
1151
|
+
return tools.deleteScriptLines(body.instancePath, startLine, endLine, body.instance_id);
|
|
1152
|
+
},
|
|
885
1153
|
set_attribute: (tools, body) => tools.setAttribute(body.instancePath, body.attributeName, body.attributeValue, body.valueType, body.instance_id),
|
|
886
1154
|
get_attributes: (tools, body) => tools.getAttributes(body.instancePath, body.instance_id),
|
|
887
1155
|
delete_attribute: (tools, body) => tools.deleteAttribute(body.instancePath, body.attributeName, body.instance_id),
|
|
@@ -903,8 +1171,8 @@ var init_http_server = __esm({
|
|
|
903
1171
|
solo_playtest: (tools, body) => tools.soloPlaytest(body.action, body.mode, body.timeout, body.instance_id),
|
|
904
1172
|
start_playtest: (tools, body) => tools.startPlaytest(body.mode, body.numPlayers, body.instance_id),
|
|
905
1173
|
stop_playtest: (tools, body) => tools.stopPlaytest(body.instance_id),
|
|
906
|
-
multiplayer_playtest: (tools, body) => tools.multiplayerPlaytest(body.action, body.numPlayers, body.target, body.testArgs, body.value, body.timeout, body.instance_id),
|
|
907
|
-
multiplayer_test_start: (tools, body) => tools.multiplayerTestStart(body.numPlayers, body.testArgs, body.timeout, body.instance_id),
|
|
1174
|
+
multiplayer_playtest: (tools, body) => tools.multiplayerPlaytest(body.action, body.numPlayers, body.target, body.testArgs, body.value, body.timeout, body.instance_id, body.force),
|
|
1175
|
+
multiplayer_test_start: (tools, body) => tools.multiplayerTestStart(body.numPlayers, body.testArgs, body.timeout, body.instance_id, body.force),
|
|
908
1176
|
multiplayer_test_state: (tools, body) => tools.multiplayerTestState(body.instance_id),
|
|
909
1177
|
multiplayer_test_add_players: (tools, body) => tools.multiplayerTestAddPlayers(body.numPlayers, body.timeout, body.instance_id),
|
|
910
1178
|
multiplayer_test_leave_client: (tools, body) => tools.multiplayerTestLeaveClient(body.target, body.timeout, body.instance_id),
|
|
@@ -1739,11 +2007,300 @@ var init_roblox_cookie_client = __esm({
|
|
|
1739
2007
|
}
|
|
1740
2008
|
});
|
|
1741
2009
|
|
|
1742
|
-
// ../core/dist/
|
|
1743
|
-
import
|
|
1744
|
-
import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync, statSync } from "fs";
|
|
2010
|
+
// ../core/dist/managed-instance-registry.js
|
|
2011
|
+
import * as fs from "fs";
|
|
1745
2012
|
import * as os from "os";
|
|
1746
2013
|
import * as path from "path";
|
|
2014
|
+
function defaultManagedInstanceRegistryDir() {
|
|
2015
|
+
if (process.env.ROBLOXSTUDIO_MCP_MANAGED_INSTANCE_REGISTRY_DIR) {
|
|
2016
|
+
return process.env.ROBLOXSTUDIO_MCP_MANAGED_INSTANCE_REGISTRY_DIR;
|
|
2017
|
+
}
|
|
2018
|
+
if (process.platform === "win32" && process.env.LOCALAPPDATA) {
|
|
2019
|
+
return path.join(process.env.LOCALAPPDATA, "robloxstudio-mcp", "managed-instances", "v1");
|
|
2020
|
+
}
|
|
2021
|
+
if (process.platform === "darwin") {
|
|
2022
|
+
return path.join(os.homedir(), "Library", "Application Support", "robloxstudio-mcp", "managed-instances", "v1");
|
|
2023
|
+
}
|
|
2024
|
+
return path.join(os.homedir(), ".local", "state", "robloxstudio-mcp", "managed-instances", "v1");
|
|
2025
|
+
}
|
|
2026
|
+
function sleepSync(ms) {
|
|
2027
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
2028
|
+
}
|
|
2029
|
+
function ymd(timestamp) {
|
|
2030
|
+
return new Date(timestamp).toISOString().slice(0, 10);
|
|
2031
|
+
}
|
|
2032
|
+
function eventLogDate(name) {
|
|
2033
|
+
const match = name.match(/^events-(\d{4}-\d{2}-\d{2})\.jsonl$/);
|
|
2034
|
+
return match?.[1];
|
|
2035
|
+
}
|
|
2036
|
+
function isRecord(value) {
|
|
2037
|
+
if (!value || typeof value !== "object")
|
|
2038
|
+
return false;
|
|
2039
|
+
const record = value;
|
|
2040
|
+
return record.version === REGISTRY_VERSION && typeof record.recordId === "string" && typeof record.source === "string" && typeof record.exe === "string" && Array.isArray(record.args) && typeof record.launchedAt === "number" && typeof record.bootId === "string";
|
|
2041
|
+
}
|
|
2042
|
+
var REGISTRY_VERSION, LOCK_STALE_MS, LOCK_RETRY_MS, LOCK_TIMEOUT_MS, EVENT_RETENTION_DAYS, ManagedInstanceRegistry;
|
|
2043
|
+
var init_managed_instance_registry = __esm({
|
|
2044
|
+
"../core/dist/managed-instance-registry.js"() {
|
|
2045
|
+
"use strict";
|
|
2046
|
+
REGISTRY_VERSION = 1;
|
|
2047
|
+
LOCK_STALE_MS = 1e4;
|
|
2048
|
+
LOCK_RETRY_MS = 25;
|
|
2049
|
+
LOCK_TIMEOUT_MS = 5e3;
|
|
2050
|
+
EVENT_RETENTION_DAYS = 2;
|
|
2051
|
+
ManagedInstanceRegistry = class {
|
|
2052
|
+
dir;
|
|
2053
|
+
constructor(dir = defaultManagedInstanceRegistryDir()) {
|
|
2054
|
+
this.dir = dir;
|
|
2055
|
+
}
|
|
2056
|
+
upsert(record) {
|
|
2057
|
+
this.withLock(() => this.writeRecordUnlocked(record));
|
|
2058
|
+
}
|
|
2059
|
+
attachInstanceId(recordId, instanceId) {
|
|
2060
|
+
this.withLock(() => {
|
|
2061
|
+
const record = this.readRecordUnlocked(recordId);
|
|
2062
|
+
if (!record)
|
|
2063
|
+
return;
|
|
2064
|
+
record.instanceId = instanceId;
|
|
2065
|
+
record.attachedAt = Date.now();
|
|
2066
|
+
this.writeRecordUnlocked(record);
|
|
2067
|
+
});
|
|
2068
|
+
}
|
|
2069
|
+
findOpenByInstanceId(instanceId, options) {
|
|
2070
|
+
return this.withLock(() => {
|
|
2071
|
+
this.sweepUnlocked(options);
|
|
2072
|
+
return this.readOpenRecordsUnlocked().find((record) => record.instanceId === instanceId);
|
|
2073
|
+
});
|
|
2074
|
+
}
|
|
2075
|
+
findAnyByInstanceId(instanceId) {
|
|
2076
|
+
return this.withLock(() => this.readRecordsUnlocked().find((record) => record.instanceId === instanceId));
|
|
2077
|
+
}
|
|
2078
|
+
listOpen(options) {
|
|
2079
|
+
return this.withLock(() => {
|
|
2080
|
+
this.sweepUnlocked(options);
|
|
2081
|
+
return this.readOpenRecordsUnlocked();
|
|
2082
|
+
});
|
|
2083
|
+
}
|
|
2084
|
+
markClosed(recordId, closedAt = Date.now()) {
|
|
2085
|
+
this.withLock(() => {
|
|
2086
|
+
const record = this.readRecordUnlocked(recordId);
|
|
2087
|
+
if (!record)
|
|
2088
|
+
return;
|
|
2089
|
+
record.closedAt = closedAt;
|
|
2090
|
+
this.writeRecordUnlocked(record);
|
|
2091
|
+
});
|
|
2092
|
+
}
|
|
2093
|
+
delete(recordId) {
|
|
2094
|
+
this.withLock(() => this.deleteRecordUnlocked(recordId));
|
|
2095
|
+
}
|
|
2096
|
+
sweep(options) {
|
|
2097
|
+
this.withLock(() => this.sweepUnlocked(options));
|
|
2098
|
+
}
|
|
2099
|
+
logEvent(event, now = Date.now()) {
|
|
2100
|
+
this.withLock(() => this.appendEventUnlocked(event, now));
|
|
2101
|
+
}
|
|
2102
|
+
withLock(fn) {
|
|
2103
|
+
this.ensureDir();
|
|
2104
|
+
const lockDir = path.join(this.dir, ".lock");
|
|
2105
|
+
const deadline = Date.now() + LOCK_TIMEOUT_MS;
|
|
2106
|
+
while (true) {
|
|
2107
|
+
try {
|
|
2108
|
+
fs.mkdirSync(lockDir);
|
|
2109
|
+
break;
|
|
2110
|
+
} catch (error) {
|
|
2111
|
+
const code = error.code;
|
|
2112
|
+
if (code !== "EEXIST")
|
|
2113
|
+
throw error;
|
|
2114
|
+
try {
|
|
2115
|
+
const stat = fs.statSync(lockDir);
|
|
2116
|
+
if (Date.now() - stat.mtimeMs > LOCK_STALE_MS) {
|
|
2117
|
+
fs.rmSync(lockDir, { recursive: true, force: true });
|
|
2118
|
+
continue;
|
|
2119
|
+
}
|
|
2120
|
+
} catch {
|
|
2121
|
+
continue;
|
|
2122
|
+
}
|
|
2123
|
+
if (Date.now() > deadline) {
|
|
2124
|
+
throw new Error(`Timed out waiting for managed instance registry lock: ${lockDir}`);
|
|
2125
|
+
}
|
|
2126
|
+
sleepSync(LOCK_RETRY_MS);
|
|
2127
|
+
}
|
|
2128
|
+
}
|
|
2129
|
+
try {
|
|
2130
|
+
return fn();
|
|
2131
|
+
} finally {
|
|
2132
|
+
fs.rmSync(lockDir, { recursive: true, force: true });
|
|
2133
|
+
}
|
|
2134
|
+
}
|
|
2135
|
+
ensureDir() {
|
|
2136
|
+
fs.mkdirSync(this.dir, { recursive: true });
|
|
2137
|
+
}
|
|
2138
|
+
recordPath(recordId) {
|
|
2139
|
+
return path.join(this.dir, `${recordId}.json`);
|
|
2140
|
+
}
|
|
2141
|
+
recordFilesUnlocked() {
|
|
2142
|
+
return fs.readdirSync(this.dir).filter((name) => name.endsWith(".json")).map((name) => path.join(this.dir, name));
|
|
2143
|
+
}
|
|
2144
|
+
readRecordUnlocked(recordId) {
|
|
2145
|
+
try {
|
|
2146
|
+
const parsed = JSON.parse(fs.readFileSync(this.recordPath(recordId), "utf8"));
|
|
2147
|
+
return isRecord(parsed) ? parsed : void 0;
|
|
2148
|
+
} catch {
|
|
2149
|
+
return void 0;
|
|
2150
|
+
}
|
|
2151
|
+
}
|
|
2152
|
+
readOpenRecordsUnlocked() {
|
|
2153
|
+
return this.readRecordsUnlocked().filter((record) => record.closedAt === void 0);
|
|
2154
|
+
}
|
|
2155
|
+
readRecordsUnlocked() {
|
|
2156
|
+
const records = [];
|
|
2157
|
+
for (const file of this.recordFilesUnlocked()) {
|
|
2158
|
+
try {
|
|
2159
|
+
const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
2160
|
+
if (!isRecord(parsed))
|
|
2161
|
+
continue;
|
|
2162
|
+
records.push(parsed);
|
|
2163
|
+
} catch {
|
|
2164
|
+
}
|
|
2165
|
+
}
|
|
2166
|
+
return records;
|
|
2167
|
+
}
|
|
2168
|
+
writeRecordUnlocked(record) {
|
|
2169
|
+
this.ensureDir();
|
|
2170
|
+
const finalPath = this.recordPath(record.recordId);
|
|
2171
|
+
const tmpPath = path.join(this.dir, `${record.recordId}.${process.pid}.${Date.now()}.tmp`);
|
|
2172
|
+
const fd = fs.openSync(tmpPath, "w");
|
|
2173
|
+
try {
|
|
2174
|
+
fs.writeFileSync(fd, `${JSON.stringify(record, null, 2)}
|
|
2175
|
+
`, "utf8");
|
|
2176
|
+
fs.fsyncSync(fd);
|
|
2177
|
+
} finally {
|
|
2178
|
+
fs.closeSync(fd);
|
|
2179
|
+
}
|
|
2180
|
+
fs.renameSync(tmpPath, finalPath);
|
|
2181
|
+
}
|
|
2182
|
+
deleteRecordUnlocked(recordId) {
|
|
2183
|
+
fs.rmSync(this.recordPath(recordId), { force: true });
|
|
2184
|
+
}
|
|
2185
|
+
appendEventUnlocked(event, now) {
|
|
2186
|
+
const file = path.join(this.dir, `events-${ymd(now)}.jsonl`);
|
|
2187
|
+
fs.appendFileSync(file, `${JSON.stringify({
|
|
2188
|
+
ts: new Date(now).toISOString(),
|
|
2189
|
+
...event
|
|
2190
|
+
})}
|
|
2191
|
+
`, "utf8");
|
|
2192
|
+
}
|
|
2193
|
+
cleanupOldEventLogsUnlocked(now) {
|
|
2194
|
+
const cutoff = ymd(now - EVENT_RETENTION_DAYS * 24 * 60 * 60 * 1e3);
|
|
2195
|
+
for (const name of fs.readdirSync(this.dir)) {
|
|
2196
|
+
const date = eventLogDate(name);
|
|
2197
|
+
if (!date || date >= cutoff)
|
|
2198
|
+
continue;
|
|
2199
|
+
try {
|
|
2200
|
+
fs.rmSync(path.join(this.dir, name), { force: true });
|
|
2201
|
+
} catch {
|
|
2202
|
+
}
|
|
2203
|
+
}
|
|
2204
|
+
}
|
|
2205
|
+
cleanupRecord(options, record) {
|
|
2206
|
+
try {
|
|
2207
|
+
options.cleanupRecord?.(record);
|
|
2208
|
+
} catch {
|
|
2209
|
+
this.appendEventUnlocked({
|
|
2210
|
+
event: "registry_cleanup_failed",
|
|
2211
|
+
recordId: record.recordId,
|
|
2212
|
+
instanceId: record.instanceId,
|
|
2213
|
+
source: record.source,
|
|
2214
|
+
reason: "cleanup_record_error"
|
|
2215
|
+
}, options.now ?? Date.now());
|
|
2216
|
+
}
|
|
2217
|
+
}
|
|
2218
|
+
sweepUnlocked(options) {
|
|
2219
|
+
const now = options.now ?? Date.now();
|
|
2220
|
+
this.cleanupOldEventLogsUnlocked(now);
|
|
2221
|
+
for (const file of this.recordFilesUnlocked()) {
|
|
2222
|
+
let parsed;
|
|
2223
|
+
try {
|
|
2224
|
+
parsed = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
2225
|
+
} catch {
|
|
2226
|
+
fs.rmSync(file, { force: true });
|
|
2227
|
+
this.appendEventUnlocked({
|
|
2228
|
+
event: "registry_pruned_malformed_record",
|
|
2229
|
+
reason: "parse_error",
|
|
2230
|
+
action: "deleted_record"
|
|
2231
|
+
}, now);
|
|
2232
|
+
continue;
|
|
2233
|
+
}
|
|
2234
|
+
if (!parsed || typeof parsed !== "object") {
|
|
2235
|
+
fs.rmSync(file, { force: true });
|
|
2236
|
+
this.appendEventUnlocked({
|
|
2237
|
+
event: "registry_pruned_malformed_record",
|
|
2238
|
+
reason: "invalid_shape",
|
|
2239
|
+
action: "deleted_record"
|
|
2240
|
+
}, now);
|
|
2241
|
+
continue;
|
|
2242
|
+
}
|
|
2243
|
+
const version = parsed.version;
|
|
2244
|
+
if (typeof version === "number" && version > REGISTRY_VERSION)
|
|
2245
|
+
continue;
|
|
2246
|
+
if (!isRecord(parsed)) {
|
|
2247
|
+
fs.rmSync(file, { force: true });
|
|
2248
|
+
this.appendEventUnlocked({
|
|
2249
|
+
event: "registry_pruned_malformed_record",
|
|
2250
|
+
reason: "invalid_shape",
|
|
2251
|
+
action: "deleted_record"
|
|
2252
|
+
}, now);
|
|
2253
|
+
continue;
|
|
2254
|
+
}
|
|
2255
|
+
if (parsed.closedAt !== void 0) {
|
|
2256
|
+
fs.rmSync(file, { force: true });
|
|
2257
|
+
this.appendEventUnlocked({
|
|
2258
|
+
event: "registry_pruned_closed_record",
|
|
2259
|
+
recordId: parsed.recordId,
|
|
2260
|
+
instanceId: parsed.instanceId,
|
|
2261
|
+
source: parsed.source,
|
|
2262
|
+
reason: "closed_at_present",
|
|
2263
|
+
action: "deleted_record"
|
|
2264
|
+
}, now);
|
|
2265
|
+
continue;
|
|
2266
|
+
}
|
|
2267
|
+
if (parsed.bootId !== options.currentBootId) {
|
|
2268
|
+
this.cleanupRecord(options, parsed);
|
|
2269
|
+
fs.rmSync(file, { force: true });
|
|
2270
|
+
this.appendEventUnlocked({
|
|
2271
|
+
event: "registry_pruned_previous_boot",
|
|
2272
|
+
recordId: parsed.recordId,
|
|
2273
|
+
instanceId: parsed.instanceId,
|
|
2274
|
+
source: parsed.source,
|
|
2275
|
+
reason: "boot_id_changed",
|
|
2276
|
+
action: "deleted_record_and_cleaned_baseplate"
|
|
2277
|
+
}, now);
|
|
2278
|
+
continue;
|
|
2279
|
+
}
|
|
2280
|
+
if (options.isProcessRunning && (parsed.nativeProcessId || parsed.spawnPid) && !options.isProcessRunning(parsed)) {
|
|
2281
|
+
this.cleanupRecord(options, parsed);
|
|
2282
|
+
fs.rmSync(file, { force: true });
|
|
2283
|
+
this.appendEventUnlocked({
|
|
2284
|
+
event: "registry_pruned_stale_process",
|
|
2285
|
+
recordId: parsed.recordId,
|
|
2286
|
+
instanceId: parsed.instanceId,
|
|
2287
|
+
source: parsed.source,
|
|
2288
|
+
reason: "pid_not_running",
|
|
2289
|
+
action: "deleted_record_and_cleaned_baseplate"
|
|
2290
|
+
}, now);
|
|
2291
|
+
}
|
|
2292
|
+
}
|
|
2293
|
+
}
|
|
2294
|
+
};
|
|
2295
|
+
}
|
|
2296
|
+
});
|
|
2297
|
+
|
|
2298
|
+
// ../core/dist/studio-instance-manager.js
|
|
2299
|
+
import { execFileSync, spawn } from "child_process";
|
|
2300
|
+
import { copyFileSync, existsSync, mkdirSync as mkdirSync2, readdirSync as readdirSync2, readFileSync as readFileSync2, realpathSync, rmSync as rmSync2, statSync as statSync2 } from "fs";
|
|
2301
|
+
import { randomUUID } from "crypto";
|
|
2302
|
+
import * as os2 from "os";
|
|
2303
|
+
import * as path2 from "path";
|
|
1747
2304
|
function run(command, args, options = {}) {
|
|
1748
2305
|
return execFileSync(command, args, {
|
|
1749
2306
|
encoding: "utf8",
|
|
@@ -1755,7 +2312,7 @@ function isWsl() {
|
|
|
1755
2312
|
if (process.platform !== "linux")
|
|
1756
2313
|
return false;
|
|
1757
2314
|
try {
|
|
1758
|
-
return /microsoft|wsl/i.test(
|
|
2315
|
+
return /microsoft|wsl/i.test(readFileSync2("/proc/version", "utf8"));
|
|
1759
2316
|
} catch {
|
|
1760
2317
|
return false;
|
|
1761
2318
|
}
|
|
@@ -1784,7 +2341,7 @@ function toWslPath(windowsPath) {
|
|
|
1784
2341
|
return run("wslpath", ["-u", windowsPath]);
|
|
1785
2342
|
}
|
|
1786
2343
|
function toStudioLaunchArg(arg) {
|
|
1787
|
-
if (!isWsl() || !
|
|
2344
|
+
if (!isWsl() || !path2.isAbsolute(arg) || !existsSync(arg))
|
|
1788
2345
|
return arg;
|
|
1789
2346
|
return run("wslpath", ["-w", arg]);
|
|
1790
2347
|
}
|
|
@@ -1793,21 +2350,21 @@ function resolveEntrypointDir() {
|
|
|
1793
2350
|
if (!entrypoint)
|
|
1794
2351
|
return void 0;
|
|
1795
2352
|
try {
|
|
1796
|
-
return
|
|
2353
|
+
return path2.dirname(realpathSync(entrypoint));
|
|
1797
2354
|
} catch {
|
|
1798
|
-
return
|
|
2355
|
+
return path2.dirname(path2.resolve(entrypoint));
|
|
1799
2356
|
}
|
|
1800
2357
|
}
|
|
1801
2358
|
function resolveBaseplateTemplatePath() {
|
|
1802
2359
|
const entrypointDir = resolveEntrypointDir();
|
|
1803
2360
|
const candidates = [
|
|
1804
2361
|
...entrypointDir ? [
|
|
1805
|
-
|
|
1806
|
-
|
|
2362
|
+
path2.join(entrypointDir, "assets", BASEPLATE_TEMPLATE_NAME),
|
|
2363
|
+
path2.join(entrypointDir, "..", "assets", BASEPLATE_TEMPLATE_NAME)
|
|
1807
2364
|
] : [],
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
2365
|
+
path2.join(process.cwd(), "packages", "core", "assets", BASEPLATE_TEMPLATE_NAME),
|
|
2366
|
+
path2.join(process.cwd(), "packages", "robloxstudio-mcp", "dist", "assets", BASEPLATE_TEMPLATE_NAME),
|
|
2367
|
+
path2.join(process.cwd(), "assets", BASEPLATE_TEMPLATE_NAME)
|
|
1811
2368
|
];
|
|
1812
2369
|
for (const candidate of candidates) {
|
|
1813
2370
|
if (existsSync(candidate))
|
|
@@ -1816,22 +2373,22 @@ function resolveBaseplateTemplatePath() {
|
|
|
1816
2373
|
throw new Error(`Baseplate template not found. Expected ${BASEPLATE_TEMPLATE_NAME} in one of: ${candidates.join(", ")}`);
|
|
1817
2374
|
}
|
|
1818
2375
|
function createBaseplatePlaceFile() {
|
|
1819
|
-
|
|
1820
|
-
const file =
|
|
2376
|
+
mkdirSync2(BASEPLATE_TEMP_DIR, { recursive: true });
|
|
2377
|
+
const file = path2.join(BASEPLATE_TEMP_DIR, `Baseplate-${process.pid}-${Date.now()}.rbxl`);
|
|
1821
2378
|
copyFileSync(resolveBaseplateTemplatePath(), file);
|
|
1822
2379
|
return file;
|
|
1823
2380
|
}
|
|
1824
2381
|
function isGeneratedBaseplatePlaceFile(file) {
|
|
1825
|
-
const resolvedFile =
|
|
1826
|
-
return
|
|
2382
|
+
const resolvedFile = path2.resolve(file);
|
|
2383
|
+
return path2.dirname(resolvedFile) === path2.resolve(BASEPLATE_TEMP_DIR) && BASEPLATE_TEMP_NAME.test(path2.basename(resolvedFile));
|
|
1827
2384
|
}
|
|
1828
2385
|
function cleanupManagedBaseplateFiles(record) {
|
|
1829
2386
|
if (record.source !== "baseplate" || !record.localPlaceFile)
|
|
1830
2387
|
return;
|
|
1831
2388
|
if (!isGeneratedBaseplatePlaceFile(record.localPlaceFile))
|
|
1832
2389
|
return;
|
|
1833
|
-
|
|
1834
|
-
|
|
2390
|
+
rmSync2(record.localPlaceFile, { force: true });
|
|
2391
|
+
rmSync2(`${record.localPlaceFile}.lock`, { force: true });
|
|
1835
2392
|
}
|
|
1836
2393
|
function prepareStudioLaunchOptions(options) {
|
|
1837
2394
|
if (options.source !== "baseplate" || options.localPlaceFile)
|
|
@@ -1851,11 +2408,11 @@ function resolveStudioExe() {
|
|
|
1851
2408
|
throw new Error("Roblox Studio executable auto-discovery is only supported on Windows, WSL, and macOS. Set ROBLOX_STUDIO_EXE.");
|
|
1852
2409
|
}
|
|
1853
2410
|
const localAppData = windowsLocalAppData();
|
|
1854
|
-
const root = localAppData ?
|
|
2411
|
+
const root = localAppData ? path2.join(toWslPath(localAppData), "Roblox", "Versions") : path2.join(os2.homedir(), "AppData", "Local", "Roblox", "Versions");
|
|
1855
2412
|
if (!existsSync(root)) {
|
|
1856
2413
|
throw new Error(`Roblox Studio Versions folder not found: ${root}. Set ROBLOX_STUDIO_EXE.`);
|
|
1857
2414
|
}
|
|
1858
|
-
const candidates =
|
|
2415
|
+
const candidates = readdirSync2(root).filter((name) => name.startsWith("version-")).map((name) => path2.join(root, name, "RobloxStudioBeta.exe")).filter((candidate) => existsSync(candidate)).sort((a, b) => statSync2(b).mtimeMs - statSync2(a).mtimeMs);
|
|
1859
2416
|
if (candidates.length === 0) {
|
|
1860
2417
|
throw new Error(`RobloxStudioBeta.exe not found under ${root}. Set ROBLOX_STUDIO_EXE.`);
|
|
1861
2418
|
}
|
|
@@ -1871,14 +2428,14 @@ function listStudioProcesses() {
|
|
|
1871
2428
|
}
|
|
1872
2429
|
return out2.split("\n").filter(Boolean).map((line) => {
|
|
1873
2430
|
const [pid, ...rest] = line.trim().split(/\s+/);
|
|
1874
|
-
return { Id: Number(pid), Path: rest.join(" "), MainWindowTitle: "" };
|
|
2431
|
+
return { Id: Number(pid), Name: "RobloxStudio", Path: rest.join(" "), MainWindowTitle: "" };
|
|
1875
2432
|
}).filter((proc) => Number.isFinite(proc.Id));
|
|
1876
2433
|
}
|
|
1877
2434
|
if (process.platform !== "win32" && !isWsl())
|
|
1878
2435
|
return [];
|
|
1879
2436
|
let out = "";
|
|
1880
2437
|
try {
|
|
1881
|
-
out = powershell("Get-Process RobloxStudioBeta -ErrorAction SilentlyContinue | Select-Object Id,Path,MainWindowTitle | ConvertTo-Json -Compress");
|
|
2438
|
+
out = powershell("Get-Process RobloxStudioBeta -ErrorAction SilentlyContinue | Select-Object Id,Name,Path,MainWindowTitle | ConvertTo-Json -Compress");
|
|
1882
2439
|
} catch {
|
|
1883
2440
|
return [];
|
|
1884
2441
|
}
|
|
@@ -1887,6 +2444,27 @@ function listStudioProcesses() {
|
|
|
1887
2444
|
const parsed = JSON.parse(out);
|
|
1888
2445
|
return Array.isArray(parsed) ? parsed : [parsed];
|
|
1889
2446
|
}
|
|
2447
|
+
function currentBootId() {
|
|
2448
|
+
if (process.platform === "linux") {
|
|
2449
|
+
try {
|
|
2450
|
+
return readFileSync2("/proc/sys/kernel/random/boot_id", "utf8").trim();
|
|
2451
|
+
} catch {
|
|
2452
|
+
}
|
|
2453
|
+
}
|
|
2454
|
+
if (process.platform === "win32" || isWsl()) {
|
|
2455
|
+
try {
|
|
2456
|
+
return powershell('(Get-CimInstance Win32_OperatingSystem).LastBootUpTime.ToUniversalTime().ToString("o")');
|
|
2457
|
+
} catch {
|
|
2458
|
+
}
|
|
2459
|
+
}
|
|
2460
|
+
if (process.platform === "darwin") {
|
|
2461
|
+
try {
|
|
2462
|
+
return run("sysctl", ["-n", "kern.boottime"]);
|
|
2463
|
+
} catch {
|
|
2464
|
+
}
|
|
2465
|
+
}
|
|
2466
|
+
return `${process.platform}:${os2.hostname()}:unknown-boot`;
|
|
2467
|
+
}
|
|
1890
2468
|
function buildStudioLaunchArgs(options) {
|
|
1891
2469
|
switch (options.source) {
|
|
1892
2470
|
case "baseplate":
|
|
@@ -1899,13 +2477,13 @@ function buildStudioLaunchArgs(options) {
|
|
|
1899
2477
|
if (!options.placeId)
|
|
1900
2478
|
throw new Error('place_id is required when source="published_place".');
|
|
1901
2479
|
if (!options.universeId)
|
|
1902
|
-
throw new Error('
|
|
2480
|
+
throw new Error('Derived universe id is required when source="published_place".');
|
|
1903
2481
|
return ["--task", "EditPlace", "--placeId", String(options.placeId), "--universeId", String(options.universeId)];
|
|
1904
2482
|
case "place_revision":
|
|
1905
2483
|
if (!options.placeId)
|
|
1906
2484
|
throw new Error('place_id is required when source="place_revision".');
|
|
1907
2485
|
if (!options.universeId)
|
|
1908
|
-
throw new Error('
|
|
2486
|
+
throw new Error('Derived universe id is required when source="place_revision".');
|
|
1909
2487
|
if (!options.placeVersion)
|
|
1910
2488
|
throw new Error('place_version is required when launching source="place_revision".');
|
|
1911
2489
|
return [
|
|
@@ -1923,39 +2501,69 @@ function buildStudioLaunchArgs(options) {
|
|
|
1923
2501
|
function delay(ms) {
|
|
1924
2502
|
return new Promise((resolve4) => setTimeout(resolve4, ms));
|
|
1925
2503
|
}
|
|
2504
|
+
function basenameAny(filePath) {
|
|
2505
|
+
return path2.basename(filePath.replace(/\\/g, "/"));
|
|
2506
|
+
}
|
|
1926
2507
|
var BASEPLATE_TEMP_DIR, BASEPLATE_TEMP_NAME, BASEPLATE_TEMPLATE_NAME, StudioInstanceManager;
|
|
1927
2508
|
var init_studio_instance_manager = __esm({
|
|
1928
2509
|
"../core/dist/studio-instance-manager.js"() {
|
|
1929
2510
|
"use strict";
|
|
1930
|
-
|
|
2511
|
+
init_managed_instance_registry();
|
|
2512
|
+
BASEPLATE_TEMP_DIR = path2.join(os2.tmpdir(), "robloxstudio-mcp-baseplates");
|
|
1931
2513
|
BASEPLATE_TEMP_NAME = /^Baseplate-\d+-\d+\.rbxl$/;
|
|
1932
2514
|
BASEPLATE_TEMPLATE_NAME = "Baseplate.rbxl";
|
|
1933
2515
|
StudioInstanceManager = class {
|
|
1934
2516
|
managedByInstanceId = /* @__PURE__ */ new Map();
|
|
1935
2517
|
pending = /* @__PURE__ */ new Set();
|
|
2518
|
+
registry;
|
|
2519
|
+
processAdapter;
|
|
2520
|
+
constructor(options = {}) {
|
|
2521
|
+
this.registry = options.registry ?? new ManagedInstanceRegistry(options.registryDir);
|
|
2522
|
+
this.processAdapter = options.processAdapter ?? {};
|
|
2523
|
+
}
|
|
1936
2524
|
list() {
|
|
1937
|
-
|
|
2525
|
+
this.sweepRegistry();
|
|
2526
|
+
const records = [...this.managedByInstanceId.values(), ...this.pending];
|
|
2527
|
+
for (const registryRecord of this.registry.listOpen(this.registrySweepOptions())) {
|
|
2528
|
+
const record = this.fromRegistryRecord(registryRecord);
|
|
2529
|
+
if (records.some((existing) => record.recordId && existing.recordId === record.recordId || record.instanceId && existing.instanceId === record.instanceId)) {
|
|
2530
|
+
continue;
|
|
2531
|
+
}
|
|
2532
|
+
records.push(record);
|
|
2533
|
+
}
|
|
2534
|
+
return records.filter((instance, index, all) => all.indexOf(instance) === index);
|
|
1938
2535
|
}
|
|
1939
2536
|
get(instanceId) {
|
|
1940
|
-
|
|
2537
|
+
this.sweepRegistry();
|
|
2538
|
+
const memoryRecord = this.managedByInstanceId.get(instanceId);
|
|
2539
|
+
if (memoryRecord)
|
|
2540
|
+
return memoryRecord;
|
|
2541
|
+
const registryRecord = this.registry.findOpenByInstanceId(instanceId, this.registrySweepOptions());
|
|
2542
|
+
return registryRecord ? this.fromRegistryRecord(registryRecord) : void 0;
|
|
1941
2543
|
}
|
|
1942
2544
|
attachInstanceId(record, instanceId) {
|
|
1943
2545
|
record.instanceId = instanceId;
|
|
1944
2546
|
this.pending.delete(record);
|
|
1945
2547
|
this.managedByInstanceId.set(instanceId, record);
|
|
2548
|
+
if (record.recordId) {
|
|
2549
|
+
this.registry.attachInstanceId(record.recordId, instanceId);
|
|
2550
|
+
}
|
|
1946
2551
|
}
|
|
1947
2552
|
async launch(options) {
|
|
2553
|
+
this.sweepRegistry();
|
|
1948
2554
|
const preparedOptions = prepareStudioLaunchOptions(options);
|
|
1949
|
-
const before = new Set(listStudioProcesses().map((proc2) => proc2.Id));
|
|
1950
|
-
const exe = resolveStudioExe();
|
|
2555
|
+
const before = new Set(this.listStudioProcesses().map((proc2) => proc2.Id));
|
|
2556
|
+
const exe = this.processAdapter.resolveStudioExe?.() ?? resolveStudioExe();
|
|
1951
2557
|
const args = buildStudioLaunchArgs(preparedOptions).map(toStudioLaunchArg);
|
|
1952
|
-
const
|
|
2558
|
+
const spawnOptions = {
|
|
1953
2559
|
cwd: isWsl() && existsSync("/mnt/c/Windows") ? "/mnt/c/Windows" : process.cwd(),
|
|
1954
2560
|
detached: true,
|
|
1955
2561
|
stdio: "ignore"
|
|
1956
|
-
}
|
|
2562
|
+
};
|
|
2563
|
+
const proc = this.processAdapter.spawnStudio ? this.processAdapter.spawnStudio(exe, args, spawnOptions) : spawn(exe, args, spawnOptions);
|
|
1957
2564
|
proc.unref();
|
|
1958
2565
|
const record = {
|
|
2566
|
+
recordId: randomUUID(),
|
|
1959
2567
|
source: options.source,
|
|
1960
2568
|
spawnPid: proc.pid,
|
|
1961
2569
|
exe,
|
|
@@ -1964,43 +2572,119 @@ var init_studio_instance_manager = __esm({
|
|
|
1964
2572
|
universeId: preparedOptions.universeId,
|
|
1965
2573
|
placeVersion: preparedOptions.placeVersion,
|
|
1966
2574
|
localPlaceFile: preparedOptions.localPlaceFile,
|
|
1967
|
-
launchedAt: Date.now()
|
|
2575
|
+
launchedAt: Date.now(),
|
|
2576
|
+
ownerPid: process.pid,
|
|
2577
|
+
bootId: this.getCurrentBootId(),
|
|
2578
|
+
deleteLocalPlaceFileOnClose: options.source === "baseplate"
|
|
1968
2579
|
};
|
|
1969
2580
|
this.pending.add(record);
|
|
2581
|
+
this.registry.upsert(this.toRegistryRecord(record));
|
|
1970
2582
|
const deadline = Date.now() + 5e3;
|
|
1971
2583
|
while (Date.now() < deadline && record.nativeProcessId === void 0) {
|
|
1972
|
-
const created = listStudioProcesses().find((candidate) => !before.has(candidate.Id));
|
|
2584
|
+
const created = this.listStudioProcesses().find((candidate) => !before.has(candidate.Id));
|
|
1973
2585
|
if (created) {
|
|
1974
2586
|
record.nativeProcessId = created.Id;
|
|
2587
|
+
this.registry.upsert(this.toRegistryRecord(record));
|
|
1975
2588
|
break;
|
|
1976
2589
|
}
|
|
1977
2590
|
await delay(250);
|
|
1978
2591
|
}
|
|
1979
2592
|
if (record.nativeProcessId === void 0 && process.platform !== "win32" && !isWsl()) {
|
|
1980
2593
|
record.nativeProcessId = proc.pid;
|
|
2594
|
+
this.registry.upsert(this.toRegistryRecord(record));
|
|
1981
2595
|
}
|
|
1982
2596
|
return record;
|
|
1983
2597
|
}
|
|
2598
|
+
closeByInstanceId(instanceId) {
|
|
2599
|
+
const memoryRecord = this.managedByInstanceId.get(instanceId);
|
|
2600
|
+
if (memoryRecord)
|
|
2601
|
+
return this.close(memoryRecord);
|
|
2602
|
+
const registryRecord = this.registry.findAnyByInstanceId(instanceId);
|
|
2603
|
+
if (!registryRecord) {
|
|
2604
|
+
this.sweepRegistry();
|
|
2605
|
+
return { status: "not_found", instanceId };
|
|
2606
|
+
}
|
|
2607
|
+
if (registryRecord.closedAt !== void 0) {
|
|
2608
|
+
this.cleanupManagedRecord(registryRecord);
|
|
2609
|
+
this.registry.delete(registryRecord.recordId);
|
|
2610
|
+
this.registry.logEvent({
|
|
2611
|
+
event: "registry_close_already_stopped",
|
|
2612
|
+
recordId: registryRecord.recordId,
|
|
2613
|
+
instanceId: registryRecord.instanceId,
|
|
2614
|
+
source: registryRecord.source,
|
|
2615
|
+
reason: "closed_at_present",
|
|
2616
|
+
action: "deleted_record_and_cleaned_baseplate"
|
|
2617
|
+
});
|
|
2618
|
+
return { status: "already_closed", instanceId };
|
|
2619
|
+
}
|
|
2620
|
+
if (registryRecord.bootId !== this.getCurrentBootId()) {
|
|
2621
|
+
this.cleanupManagedRecord(registryRecord);
|
|
2622
|
+
this.registry.delete(registryRecord.recordId);
|
|
2623
|
+
this.registry.logEvent({
|
|
2624
|
+
event: "registry_pruned_previous_boot",
|
|
2625
|
+
recordId: registryRecord.recordId,
|
|
2626
|
+
instanceId: registryRecord.instanceId,
|
|
2627
|
+
source: registryRecord.source,
|
|
2628
|
+
reason: "boot_id_changed",
|
|
2629
|
+
action: "deleted_record_and_cleaned_baseplate"
|
|
2630
|
+
});
|
|
2631
|
+
return { status: "already_closed", instanceId };
|
|
2632
|
+
}
|
|
2633
|
+
return this.close(this.fromRegistryRecord(registryRecord));
|
|
2634
|
+
}
|
|
1984
2635
|
close(record) {
|
|
1985
2636
|
const processId = record.nativeProcessId ?? record.spawnPid;
|
|
1986
2637
|
if (!processId) {
|
|
1987
2638
|
throw new Error(`Cannot close ${record.instanceId ?? "Studio launch"} because its process id was not detected.`);
|
|
1988
2639
|
}
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
2640
|
+
const studioProcess = this.findProcessById(processId);
|
|
2641
|
+
if (!studioProcess) {
|
|
2642
|
+
this.cleanupManagedRecord(record);
|
|
2643
|
+
this.markClosedInMemory(record);
|
|
2644
|
+
this.markClosedInRegistry(record);
|
|
2645
|
+
this.registry.logEvent({
|
|
2646
|
+
event: "registry_close_already_stopped",
|
|
2647
|
+
recordId: record.recordId,
|
|
2648
|
+
instanceId: record.instanceId,
|
|
2649
|
+
source: record.source,
|
|
2650
|
+
reason: "pid_not_running",
|
|
2651
|
+
action: "marked_closed_and_cleaned_baseplate"
|
|
2652
|
+
});
|
|
2653
|
+
return { status: "already_closed", instanceId: record.instanceId };
|
|
2654
|
+
}
|
|
2655
|
+
if (!this.verifyProcessForRecord(record, studioProcess)) {
|
|
2656
|
+
this.registry.logEvent({
|
|
2657
|
+
event: "registry_process_verification_failed",
|
|
2658
|
+
recordId: record.recordId,
|
|
2659
|
+
instanceId: record.instanceId,
|
|
2660
|
+
source: record.source,
|
|
2661
|
+
reason: "identity_mismatch"
|
|
2662
|
+
});
|
|
2663
|
+
throw new Error("Managed Studio process identity could not be verified.");
|
|
2664
|
+
}
|
|
2665
|
+
try {
|
|
2666
|
+
this.closeProcess(processId);
|
|
2667
|
+
} catch (error) {
|
|
2668
|
+
if (this.findProcessById(processId))
|
|
2669
|
+
throw error;
|
|
2670
|
+
this.registry.logEvent({
|
|
2671
|
+
event: "registry_close_already_stopped",
|
|
2672
|
+
recordId: record.recordId,
|
|
2673
|
+
instanceId: record.instanceId,
|
|
2674
|
+
source: record.source,
|
|
2675
|
+
reason: "stop_raced_with_exit",
|
|
2676
|
+
action: "marked_closed_and_cleaned_baseplate"
|
|
2677
|
+
});
|
|
2678
|
+
this.cleanupManagedRecord(record);
|
|
2679
|
+
this.markClosedInMemory(record);
|
|
2680
|
+
this.markClosedInRegistry(record);
|
|
2681
|
+
return { status: "already_closed", instanceId: record.instanceId };
|
|
1998
2682
|
}
|
|
1999
2683
|
record.closedAt = Date.now();
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
this.
|
|
2003
|
-
|
|
2684
|
+
this.cleanupManagedRecord(record);
|
|
2685
|
+
this.markClosedInMemory(record);
|
|
2686
|
+
this.markClosedInRegistry(record);
|
|
2687
|
+
return { status: "closed", instanceId: record.instanceId };
|
|
2004
2688
|
}
|
|
2005
2689
|
closeConnectedInstance(instance) {
|
|
2006
2690
|
const process2 = this.findProcessForConnectedInstance(instance);
|
|
@@ -2010,6 +2694,10 @@ var init_studio_instance_manager = __esm({
|
|
|
2010
2694
|
this.closeProcess(process2.Id);
|
|
2011
2695
|
}
|
|
2012
2696
|
closeProcess(processId) {
|
|
2697
|
+
if (this.processAdapter.stopProcess) {
|
|
2698
|
+
this.processAdapter.stopProcess(processId);
|
|
2699
|
+
return;
|
|
2700
|
+
}
|
|
2013
2701
|
if (process.platform === "win32" || isWsl()) {
|
|
2014
2702
|
powershell(`Stop-Process -Id ${Math.trunc(processId)} -Force -ErrorAction Stop`);
|
|
2015
2703
|
} else {
|
|
@@ -2022,7 +2710,7 @@ var init_studio_instance_manager = __esm({
|
|
|
2022
2710
|
}
|
|
2023
2711
|
}
|
|
2024
2712
|
findProcessForConnectedInstance(instance) {
|
|
2025
|
-
const processes = listStudioProcesses();
|
|
2713
|
+
const processes = this.listStudioProcesses();
|
|
2026
2714
|
if (processes.length === 0)
|
|
2027
2715
|
return void 0;
|
|
2028
2716
|
if (processes.length === 1)
|
|
@@ -2041,13 +2729,119 @@ var init_studio_instance_manager = __esm({
|
|
|
2041
2729
|
}
|
|
2042
2730
|
return void 0;
|
|
2043
2731
|
}
|
|
2732
|
+
listStudioProcesses() {
|
|
2733
|
+
return this.processAdapter.listStudioProcesses?.() ?? listStudioProcesses();
|
|
2734
|
+
}
|
|
2735
|
+
getCurrentBootId() {
|
|
2736
|
+
return this.processAdapter.currentBootId?.() ?? currentBootId();
|
|
2737
|
+
}
|
|
2738
|
+
registrySweepOptions() {
|
|
2739
|
+
return {
|
|
2740
|
+
currentBootId: this.getCurrentBootId(),
|
|
2741
|
+
isProcessRunning: (record) => this.isRegistryProcessRunning(record),
|
|
2742
|
+
cleanupRecord: (record) => this.cleanupManagedRecord(record)
|
|
2743
|
+
};
|
|
2744
|
+
}
|
|
2745
|
+
sweepRegistry() {
|
|
2746
|
+
this.registry.sweep(this.registrySweepOptions());
|
|
2747
|
+
}
|
|
2748
|
+
findProcessById(processId) {
|
|
2749
|
+
return this.listStudioProcesses().find((proc) => proc.Id === processId);
|
|
2750
|
+
}
|
|
2751
|
+
isRegistryProcessRunning(record) {
|
|
2752
|
+
const processId = record.nativeProcessId ?? record.spawnPid;
|
|
2753
|
+
if (!processId)
|
|
2754
|
+
return true;
|
|
2755
|
+
const studioProcess = this.findProcessById(processId);
|
|
2756
|
+
return !!studioProcess && this.verifyProcessForRecord(this.fromRegistryRecord(record), studioProcess);
|
|
2757
|
+
}
|
|
2758
|
+
verifyProcessForRecord(record, studioProcess) {
|
|
2759
|
+
const processName = `${studioProcess.Name ?? ""} ${studioProcess.Path ?? ""}`.toLowerCase();
|
|
2760
|
+
if (!processName.includes("robloxstudio"))
|
|
2761
|
+
return false;
|
|
2762
|
+
const processId = record.nativeProcessId ?? record.spawnPid;
|
|
2763
|
+
if (record.spawnPid && record.spawnPid === processId && studioProcess.Id === processId)
|
|
2764
|
+
return true;
|
|
2765
|
+
const processPath = studioProcess.Path ? path2.normalize(studioProcess.Path).toLowerCase() : "";
|
|
2766
|
+
const exePath = record.exe ? path2.normalize(record.exe).toLowerCase() : "";
|
|
2767
|
+
if (processPath && exePath && (processPath === exePath || basenameAny(processPath) === basenameAny(exePath))) {
|
|
2768
|
+
return true;
|
|
2769
|
+
}
|
|
2770
|
+
const commandLine = studioProcess.CommandLine ?? "";
|
|
2771
|
+
if (record.localPlaceFile && commandLine.includes(path2.basename(record.localPlaceFile)))
|
|
2772
|
+
return true;
|
|
2773
|
+
if (record.placeId !== void 0 && commandLine.includes(String(record.placeId)))
|
|
2774
|
+
return true;
|
|
2775
|
+
return false;
|
|
2776
|
+
}
|
|
2777
|
+
cleanupManagedRecord(record) {
|
|
2778
|
+
if (record.source !== "baseplate")
|
|
2779
|
+
return;
|
|
2780
|
+
cleanupManagedBaseplateFiles({ source: "baseplate", localPlaceFile: record.localPlaceFile });
|
|
2781
|
+
}
|
|
2782
|
+
markClosedInMemory(record) {
|
|
2783
|
+
record.closedAt = record.closedAt ?? Date.now();
|
|
2784
|
+
if (record.instanceId)
|
|
2785
|
+
this.managedByInstanceId.delete(record.instanceId);
|
|
2786
|
+
this.pending.delete(record);
|
|
2787
|
+
}
|
|
2788
|
+
markClosedInRegistry(record) {
|
|
2789
|
+
if (record.recordId)
|
|
2790
|
+
this.registry.markClosed(record.recordId, record.closedAt ?? Date.now());
|
|
2791
|
+
}
|
|
2792
|
+
toRegistryRecord(record) {
|
|
2793
|
+
if (!record.recordId)
|
|
2794
|
+
throw new Error("Managed Studio record is missing recordId.");
|
|
2795
|
+
if (!record.bootId)
|
|
2796
|
+
throw new Error("Managed Studio record is missing bootId.");
|
|
2797
|
+
return {
|
|
2798
|
+
version: 1,
|
|
2799
|
+
recordId: record.recordId,
|
|
2800
|
+
instanceId: record.instanceId,
|
|
2801
|
+
source: record.source,
|
|
2802
|
+
nativeProcessId: record.nativeProcessId,
|
|
2803
|
+
spawnPid: record.spawnPid,
|
|
2804
|
+
exe: record.exe,
|
|
2805
|
+
args: record.args,
|
|
2806
|
+
placeId: record.placeId,
|
|
2807
|
+
universeId: record.universeId,
|
|
2808
|
+
placeVersion: record.placeVersion,
|
|
2809
|
+
localPlaceFile: record.localPlaceFile,
|
|
2810
|
+
deleteLocalPlaceFileOnClose: record.deleteLocalPlaceFileOnClose,
|
|
2811
|
+
launchedAt: record.launchedAt,
|
|
2812
|
+
attachedAt: record.instanceId ? Date.now() : void 0,
|
|
2813
|
+
closedAt: record.closedAt,
|
|
2814
|
+
ownerPid: record.ownerPid,
|
|
2815
|
+
bootId: record.bootId
|
|
2816
|
+
};
|
|
2817
|
+
}
|
|
2818
|
+
fromRegistryRecord(record) {
|
|
2819
|
+
return {
|
|
2820
|
+
recordId: record.recordId,
|
|
2821
|
+
source: record.source,
|
|
2822
|
+
instanceId: record.instanceId,
|
|
2823
|
+
nativeProcessId: record.nativeProcessId,
|
|
2824
|
+
spawnPid: record.spawnPid,
|
|
2825
|
+
exe: record.exe,
|
|
2826
|
+
args: record.args,
|
|
2827
|
+
placeId: record.placeId,
|
|
2828
|
+
universeId: record.universeId,
|
|
2829
|
+
placeVersion: record.placeVersion,
|
|
2830
|
+
localPlaceFile: record.localPlaceFile,
|
|
2831
|
+
launchedAt: record.launchedAt,
|
|
2832
|
+
closedAt: record.closedAt,
|
|
2833
|
+
ownerPid: record.ownerPid,
|
|
2834
|
+
bootId: record.bootId,
|
|
2835
|
+
deleteLocalPlaceFileOnClose: record.deleteLocalPlaceFileOnClose
|
|
2836
|
+
};
|
|
2837
|
+
}
|
|
2044
2838
|
};
|
|
2045
2839
|
}
|
|
2046
2840
|
});
|
|
2047
2841
|
|
|
2048
2842
|
// ../core/dist/image-decode.js
|
|
2049
|
-
import * as
|
|
2050
|
-
import * as
|
|
2843
|
+
import * as fs2 from "fs";
|
|
2844
|
+
import * as path3 from "path";
|
|
2051
2845
|
import { inflateSync } from "zlib";
|
|
2052
2846
|
function paethPredictor(a, b, c) {
|
|
2053
2847
|
const p = a + b - c;
|
|
@@ -2205,11 +2999,11 @@ function decodePngToRgba(data) {
|
|
|
2205
2999
|
return resizeRgbaNearest({ width, height, rgba });
|
|
2206
3000
|
}
|
|
2207
3001
|
function decodeImagePathToRgba(imagePath) {
|
|
2208
|
-
const resolved =
|
|
2209
|
-
if (!
|
|
3002
|
+
const resolved = path3.resolve(imagePath);
|
|
3003
|
+
if (!fs2.existsSync(resolved)) {
|
|
2210
3004
|
throw new Error(`image_path not found: ${imagePath}`);
|
|
2211
3005
|
}
|
|
2212
|
-
return decodePngToRgba(
|
|
3006
|
+
return decodePngToRgba(fs2.readFileSync(resolved));
|
|
2213
3007
|
}
|
|
2214
3008
|
var PNG_SIGNATURE, MAX_EDITABLE_IMAGE_DIMENSION;
|
|
2215
3009
|
var init_image_decode = __esm({
|
|
@@ -3237,9 +4031,19 @@ var init_png_encoder = __esm({
|
|
|
3237
4031
|
});
|
|
3238
4032
|
|
|
3239
4033
|
// ../core/dist/tools/index.js
|
|
3240
|
-
import * as
|
|
3241
|
-
import * as
|
|
3242
|
-
import * as
|
|
4034
|
+
import * as fs3 from "fs";
|
|
4035
|
+
import * as os3 from "os";
|
|
4036
|
+
import * as path4 from "path";
|
|
4037
|
+
function multiplayerStopDisabledBody() {
|
|
4038
|
+
return {
|
|
4039
|
+
success: false,
|
|
4040
|
+
error: "multiplayer_stop_disabled",
|
|
4041
|
+
message: MULTIPLAYER_STOP_DISABLED_MESSAGE,
|
|
4042
|
+
reason: "StudioTestService:EndTest does not reliably end StudioTestService multiplayer sessions from MCP right now.",
|
|
4043
|
+
manualCleanupRequired: true,
|
|
4044
|
+
recoveryHint: "Close the Roblox Studio multiplayer test windows manually."
|
|
4045
|
+
};
|
|
4046
|
+
}
|
|
3243
4047
|
function encodeImageFromRgbaResponse(response, format, quality) {
|
|
3244
4048
|
if (!response.data || response.width === void 0 || response.height === void 0) {
|
|
3245
4049
|
throw new Error("Render response missing data, width, or height");
|
|
@@ -3316,8 +4120,8 @@ function loadMicroProfilerBaseline(source, sourcePath) {
|
|
|
3316
4120
|
if (typeof sourcePath !== "string" || sourcePath === "") {
|
|
3317
4121
|
throw new Error("baseline_path must be a non-empty string when provided");
|
|
3318
4122
|
}
|
|
3319
|
-
const resolved =
|
|
3320
|
-
const parsed = JSON.parse(
|
|
4123
|
+
const resolved = path4.resolve(sourcePath);
|
|
4124
|
+
const parsed = JSON.parse(fs3.readFileSync(resolved, "utf8"));
|
|
3321
4125
|
const record = asRecord(parsed);
|
|
3322
4126
|
if (!record)
|
|
3323
4127
|
throw new Error(`baseline_path did not contain a JSON object: ${resolved}`);
|
|
@@ -3765,7 +4569,7 @@ end
|
|
|
3765
4569
|
error("Unsupported device simulator operation: " .. tostring(opts.operation), 0)
|
|
3766
4570
|
`.trim();
|
|
3767
4571
|
}
|
|
3768
|
-
var MAX_INLINE_IMAGE_BYTES, MAX_DEVICE_MATRIX_ENTRIES, MAX_NETWORK_PACKET_LOSS_PERCENT, STUDIO_ASSISTANT_SOURCE_IMAGE_LABEL, NETWORK_PROFILE_KEYS, NETWORK_PROFILES, ZERO_NETWORK_PROFILE, SIMULATION_PERSISTENCE_NOTES, RobloxStudioTools;
|
|
4572
|
+
var MAX_INLINE_IMAGE_BYTES, MAX_DEVICE_MATRIX_ENTRIES, MAX_NETWORK_PACKET_LOSS_PERCENT, STUDIO_ASSISTANT_SOURCE_IMAGE_LABEL, MULTIPLAYER_FORCE_REQUIRED_MESSAGE, MULTIPLAYER_STOP_DISABLED_MESSAGE, NETWORK_PROFILE_KEYS, NETWORK_PROFILES, ZERO_NETWORK_PROFILE, SIMULATION_PERSISTENCE_NOTES, RobloxStudioTools;
|
|
3769
4573
|
var init_tools = __esm({
|
|
3770
4574
|
"../core/dist/tools/index.js"() {
|
|
3771
4575
|
"use strict";
|
|
@@ -3776,12 +4580,15 @@ var init_tools = __esm({
|
|
|
3776
4580
|
init_roblox_cookie_client();
|
|
3777
4581
|
init_studio_instance_manager();
|
|
3778
4582
|
init_image_decode();
|
|
4583
|
+
init_roblox_docs();
|
|
3779
4584
|
init_jpeg_encoder();
|
|
3780
4585
|
init_png_encoder();
|
|
3781
4586
|
MAX_INLINE_IMAGE_BYTES = 6e6;
|
|
3782
4587
|
MAX_DEVICE_MATRIX_ENTRIES = 6;
|
|
3783
4588
|
MAX_NETWORK_PACKET_LOSS_PERCENT = 0.5;
|
|
3784
4589
|
STUDIO_ASSISTANT_SOURCE_IMAGE_LABEL = "Studio Assistant Source Image";
|
|
4590
|
+
MULTIPLAYER_FORCE_REQUIRED_MESSAGE = "StudioTestService multiplayer stop is currently disabled because StudioTestService:EndTest is broken for this flow. Pass force=true only if you understand you must manually close the multiplayer test windows afterward.";
|
|
4591
|
+
MULTIPLAYER_STOP_DISABLED_MESSAGE = "Multiplayer playtest stop/end is disabled because StudioTestService:EndTest is currently broken for this flow. Manually close the Studio multiplayer test windows instead.";
|
|
3785
4592
|
NETWORK_PROFILE_KEYS = [
|
|
3786
4593
|
"InboundNetworkMinDelayMs",
|
|
3787
4594
|
"OutboundNetworkMinDelayMs",
|
|
@@ -3845,6 +4652,17 @@ var init_tools = __esm({
|
|
|
3845
4652
|
_textResult(body) {
|
|
3846
4653
|
return { content: [{ type: "text", text: JSON.stringify(body) }] };
|
|
3847
4654
|
}
|
|
4655
|
+
async getRobloxDocs(name, docType, section) {
|
|
4656
|
+
if (!name || typeof name !== "string") {
|
|
4657
|
+
throw new Error('get_roblox_docs requires a name (e.g. "ProximityPrompt")');
|
|
4658
|
+
}
|
|
4659
|
+
const category = docType ?? "classes";
|
|
4660
|
+
if (!isDocCategory(category)) {
|
|
4661
|
+
throw new Error(`Invalid doc_type "${category}". Valid categories: ${DOC_CATEGORIES.join(", ")}`);
|
|
4662
|
+
}
|
|
4663
|
+
const result = await getRobloxDoc(category, name.trim(), section);
|
|
4664
|
+
return { content: [{ type: "text", text: result.content }] };
|
|
4665
|
+
}
|
|
3848
4666
|
_parseTextResult(result) {
|
|
3849
4667
|
const text = result?.content?.[0]?.text;
|
|
3850
4668
|
if (typeof text !== "string")
|
|
@@ -4186,8 +5004,8 @@ var init_tools = __esm({
|
|
|
4186
5004
|
timedOut: true
|
|
4187
5005
|
};
|
|
4188
5006
|
}
|
|
4189
|
-
async getFileTree(
|
|
4190
|
-
const response = await this._callSingle("/api/file-tree", { path:
|
|
5007
|
+
async getFileTree(path5 = "", instance_id) {
|
|
5008
|
+
const response = await this._callSingle("/api/file-tree", { path: path5 }, void 0, instance_id);
|
|
4191
5009
|
return {
|
|
4192
5010
|
content: [
|
|
4193
5011
|
{
|
|
@@ -4304,9 +5122,9 @@ var init_tools = __esm({
|
|
|
4304
5122
|
]
|
|
4305
5123
|
};
|
|
4306
5124
|
}
|
|
4307
|
-
async getProjectStructure(
|
|
5125
|
+
async getProjectStructure(path5, maxDepth, scriptsOnly, instance_id) {
|
|
4308
5126
|
const response = await this._callSingle("/api/project-structure", {
|
|
4309
|
-
path:
|
|
5127
|
+
path: path5,
|
|
4310
5128
|
maxDepth,
|
|
4311
5129
|
scriptsOnly
|
|
4312
5130
|
}, void 0, instance_id);
|
|
@@ -4486,17 +5304,20 @@ var init_tools = __esm({
|
|
|
4486
5304
|
const topService = typeof response.topService === "string" && response.topService.length > 0 ? response.topService : pathSegments[0] === "game" ? pathSegments[1] ?? "game" : pathSegments[0];
|
|
4487
5305
|
const typeNote = scriptTypeInfo[response.className] || response.className;
|
|
4488
5306
|
const serviceNote = serviceInfo[topService] || topService;
|
|
5307
|
+
const showRange = Boolean(response.isPartial || response.truncated) && response.startLine !== void 0 && response.endLine !== void 0;
|
|
4489
5308
|
const headerLines = [
|
|
4490
5309
|
`Path: ${pathStr}`,
|
|
4491
5310
|
`Type: ${typeNote}`,
|
|
4492
5311
|
`Location: ${serviceNote}`,
|
|
4493
|
-
`Lines: ${response.lineCount} total${
|
|
5312
|
+
`Lines: ${response.lineCount} total${showRange ? ` (showing ${response.startLine}-${response.endLine})` : ""}`
|
|
4494
5313
|
];
|
|
4495
5314
|
if (response.enabled === false) {
|
|
4496
5315
|
headerLines.push(`Status: DISABLED`);
|
|
4497
5316
|
}
|
|
4498
|
-
if (response.
|
|
4499
|
-
headerLines.push(`Note:
|
|
5317
|
+
if (typeof response.note === "string" && response.note.length > 0) {
|
|
5318
|
+
headerLines.push(`Note: ${response.note}`);
|
|
5319
|
+
} else if (response.truncated) {
|
|
5320
|
+
headerLines.push(`Note: Truncated response; use line_range to read more`);
|
|
4500
5321
|
}
|
|
4501
5322
|
const header = headerLines.join("\n");
|
|
4502
5323
|
const code = response.numberedSource || response.source;
|
|
@@ -4574,7 +5395,7 @@ ${code}`
|
|
|
4574
5395
|
}
|
|
4575
5396
|
const response = await this._callSingle("/api/grep-scripts", {
|
|
4576
5397
|
pattern,
|
|
4577
|
-
...options
|
|
5398
|
+
...options ?? {}
|
|
4578
5399
|
}, void 0, instance_id);
|
|
4579
5400
|
return {
|
|
4580
5401
|
content: [
|
|
@@ -5227,9 +6048,9 @@ ${code}`
|
|
|
5227
6048
|
const rawJson = mutable.raw_json;
|
|
5228
6049
|
if (typeof rawJson === "string") {
|
|
5229
6050
|
if (typeof outputPath === "string" && outputPath !== "") {
|
|
5230
|
-
const resolvedOutputPath =
|
|
5231
|
-
|
|
5232
|
-
|
|
6051
|
+
const resolvedOutputPath = path4.resolve(outputPath);
|
|
6052
|
+
fs3.mkdirSync(path4.dirname(resolvedOutputPath), { recursive: true });
|
|
6053
|
+
fs3.writeFileSync(resolvedOutputPath, rawJson, "utf8");
|
|
5233
6054
|
mutable.output_path = resolvedOutputPath;
|
|
5234
6055
|
}
|
|
5235
6056
|
delete mutable.raw_json;
|
|
@@ -5290,9 +6111,9 @@ ${code}`
|
|
|
5290
6111
|
const rawSnapshotBase64 = mutable.raw_snapshot_base64;
|
|
5291
6112
|
if (typeof rawSnapshotBase64 === "string") {
|
|
5292
6113
|
if (typeof outputPath === "string" && outputPath !== "") {
|
|
5293
|
-
const resolvedOutputPath =
|
|
5294
|
-
|
|
5295
|
-
|
|
6114
|
+
const resolvedOutputPath = path4.resolve(outputPath);
|
|
6115
|
+
fs3.mkdirSync(path4.dirname(resolvedOutputPath), { recursive: true });
|
|
6116
|
+
fs3.writeFileSync(resolvedOutputPath, Buffer.from(rawSnapshotBase64, "base64"));
|
|
5296
6117
|
mutable.output_path = resolvedOutputPath;
|
|
5297
6118
|
}
|
|
5298
6119
|
delete mutable.raw_snapshot_base64;
|
|
@@ -5306,9 +6127,9 @@ ${code}`
|
|
|
5306
6127
|
});
|
|
5307
6128
|
}
|
|
5308
6129
|
if (typeof summaryOutputPath === "string" && summaryOutputPath !== "") {
|
|
5309
|
-
const resolvedSummaryPath =
|
|
5310
|
-
|
|
5311
|
-
|
|
6130
|
+
const resolvedSummaryPath = path4.resolve(summaryOutputPath);
|
|
6131
|
+
fs3.mkdirSync(path4.dirname(resolvedSummaryPath), { recursive: true });
|
|
6132
|
+
fs3.writeFileSync(resolvedSummaryPath, JSON.stringify(mutable, null, 2), "utf8");
|
|
5312
6133
|
mutable.summary_output_path = resolvedSummaryPath;
|
|
5313
6134
|
}
|
|
5314
6135
|
if (!includeComparisonIndex) {
|
|
@@ -5355,6 +6176,14 @@ ${code}`
|
|
|
5355
6176
|
return void 0;
|
|
5356
6177
|
return this._positiveInteger(value, name);
|
|
5357
6178
|
}
|
|
6179
|
+
_optionalFiniteNumber(value, name) {
|
|
6180
|
+
if (value === void 0 || value === null)
|
|
6181
|
+
return void 0;
|
|
6182
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
6183
|
+
throw new Error(`${name} must be a finite number.`);
|
|
6184
|
+
}
|
|
6185
|
+
return value;
|
|
6186
|
+
}
|
|
5358
6187
|
_publicInstanceKey(instance) {
|
|
5359
6188
|
return `${instance.instanceId}:${instance.role}:${instance.connectedAt}`;
|
|
5360
6189
|
}
|
|
@@ -5367,7 +6196,7 @@ ${code}`
|
|
|
5367
6196
|
return record.placeId !== void 0 && instance.placeId === record.placeId;
|
|
5368
6197
|
}
|
|
5369
6198
|
if ((record.source === "baseplate" || record.source === "local_file") && record.localPlaceFile) {
|
|
5370
|
-
const expectedName =
|
|
6199
|
+
const expectedName = path4.basename(record.localPlaceFile);
|
|
5371
6200
|
return instance.placeName === expectedName || instance.dataModelName === expectedName;
|
|
5372
6201
|
}
|
|
5373
6202
|
return true;
|
|
@@ -5376,11 +6205,11 @@ ${code}`
|
|
|
5376
6205
|
const response = await fetch(`https://apis.roblox.com/universes/v1/places/${placeId}/universe`);
|
|
5377
6206
|
if (!response.ok) {
|
|
5378
6207
|
const body = await response.text().catch(() => "");
|
|
5379
|
-
throw new Error(`Could not
|
|
6208
|
+
throw new Error(`Could not resolve the universe for place_id ${placeId} (${response.status}): ${body}`);
|
|
5380
6209
|
}
|
|
5381
6210
|
const data = await response.json();
|
|
5382
6211
|
if (typeof data.universeId !== "number" || !Number.isFinite(data.universeId)) {
|
|
5383
|
-
throw new Error(`Could not
|
|
6212
|
+
throw new Error(`Could not resolve the universe for place_id ${placeId}.`);
|
|
5384
6213
|
}
|
|
5385
6214
|
return Math.trunc(data.universeId);
|
|
5386
6215
|
}
|
|
@@ -5422,7 +6251,7 @@ ${code}`
|
|
|
5422
6251
|
});
|
|
5423
6252
|
}
|
|
5424
6253
|
const placeId2 = this._positiveInteger(request.place_id, "place_id");
|
|
5425
|
-
const rawMaxPageSize = this.
|
|
6254
|
+
const rawMaxPageSize = Math.trunc(this._optionalFiniteNumber(request.max_page_size, "max_page_size") ?? 10);
|
|
5426
6255
|
const maxPageSize = Math.max(1, Math.min(50, rawMaxPageSize));
|
|
5427
6256
|
const pageToken = typeof request.page_token === "string" ? request.page_token : void 0;
|
|
5428
6257
|
const response = await this.openCloudClient.listAssetVersions(placeId2, maxPageSize, pageToken);
|
|
@@ -5467,31 +6296,38 @@ ${code}`
|
|
|
5467
6296
|
if (action === "close") {
|
|
5468
6297
|
let record2;
|
|
5469
6298
|
if (instance_id) {
|
|
5470
|
-
|
|
5471
|
-
if (
|
|
5472
|
-
|
|
5473
|
-
|
|
5474
|
-
|
|
5475
|
-
return this._textResult({
|
|
5476
|
-
error: "Instance is not connected or managed.",
|
|
5477
|
-
instance_id
|
|
5478
|
-
});
|
|
5479
|
-
}
|
|
5480
|
-
try {
|
|
5481
|
-
this.instanceManager.closeConnectedInstance(edit);
|
|
5482
|
-
await sleep(500);
|
|
5483
|
-
} catch (error) {
|
|
5484
|
-
return this._textResult({
|
|
5485
|
-
error: error instanceof Error ? error.message : String(error),
|
|
5486
|
-
instance_id
|
|
5487
|
-
});
|
|
5488
|
-
}
|
|
5489
|
-
this.bridge.unregisterInstanceId(instance_id);
|
|
6299
|
+
const managedClose = this.instanceManager.closeByInstanceId(instance_id);
|
|
6300
|
+
if (managedClose.status !== "not_found") {
|
|
6301
|
+
await this.bridge.unregisterInstanceIdEverywhere(instance_id);
|
|
6302
|
+
await sleep(500);
|
|
6303
|
+
await this.bridge.unregisterInstanceIdEverywhere(instance_id);
|
|
5490
6304
|
return this._textResult({
|
|
5491
6305
|
instance_id,
|
|
5492
|
-
message: "Studio instance closed."
|
|
6306
|
+
message: managedClose.status === "already_closed" ? "Studio instance was already closed." : "Studio instance closed."
|
|
5493
6307
|
});
|
|
5494
6308
|
}
|
|
6309
|
+
const connected2 = this.bridge.getPublicInstances().filter((instance) => instance.instanceId === instance_id);
|
|
6310
|
+
const edit = connected2.find((instance) => instance.role === "edit");
|
|
6311
|
+
if (!edit) {
|
|
6312
|
+
return this._textResult({
|
|
6313
|
+
error: "Instance is not connected or managed.",
|
|
6314
|
+
instance_id
|
|
6315
|
+
});
|
|
6316
|
+
}
|
|
6317
|
+
try {
|
|
6318
|
+
this.instanceManager.closeConnectedInstance(edit);
|
|
6319
|
+
await sleep(500);
|
|
6320
|
+
} catch (error) {
|
|
6321
|
+
return this._textResult({
|
|
6322
|
+
error: error instanceof Error ? error.message : String(error),
|
|
6323
|
+
instance_id
|
|
6324
|
+
});
|
|
6325
|
+
}
|
|
6326
|
+
await this.bridge.unregisterInstanceIdEverywhere(instance_id);
|
|
6327
|
+
return this._textResult({
|
|
6328
|
+
instance_id,
|
|
6329
|
+
message: "Studio instance closed."
|
|
6330
|
+
});
|
|
5495
6331
|
} else {
|
|
5496
6332
|
const active = this.instanceManager.list().filter((entry) => entry.closedAt === void 0);
|
|
5497
6333
|
if (active.length === 0) {
|
|
@@ -5506,15 +6342,15 @@ ${code}`
|
|
|
5506
6342
|
record2 = active[0];
|
|
5507
6343
|
}
|
|
5508
6344
|
if (record2.instanceId)
|
|
5509
|
-
this.bridge.
|
|
5510
|
-
this.instanceManager.close(record2);
|
|
6345
|
+
await this.bridge.unregisterInstanceIdEverywhere(record2.instanceId);
|
|
6346
|
+
const closeResult = this.instanceManager.close(record2);
|
|
5511
6347
|
if (record2.instanceId) {
|
|
5512
6348
|
await sleep(500);
|
|
5513
|
-
this.bridge.
|
|
6349
|
+
await this.bridge.unregisterInstanceIdEverywhere(record2.instanceId);
|
|
5514
6350
|
}
|
|
5515
6351
|
return this._textResult({
|
|
5516
6352
|
instance_id: record2.instanceId,
|
|
5517
|
-
message: "Studio instance closed."
|
|
6353
|
+
message: closeResult.status === "already_closed" ? "Studio instance was already closed." : "Studio instance closed."
|
|
5518
6354
|
});
|
|
5519
6355
|
}
|
|
5520
6356
|
const source = request.source;
|
|
@@ -5522,8 +6358,8 @@ ${code}`
|
|
|
5522
6358
|
throw new Error("manage_instance action=launch requires source=baseplate|local_file|published_place|place_revision");
|
|
5523
6359
|
}
|
|
5524
6360
|
const launchSource = source;
|
|
5525
|
-
const placeId = launchSource === "published_place" || launchSource === "place_revision" ? this._positiveInteger(request.place_id, "place_id") :
|
|
5526
|
-
const placeVersion = launchSource === "place_revision" ? this._positiveInteger(request.place_version, "place_version") :
|
|
6361
|
+
const placeId = launchSource === "published_place" || launchSource === "place_revision" ? this._positiveInteger(request.place_id, "place_id") : void 0;
|
|
6362
|
+
const placeVersion = launchSource === "place_revision" ? this._positiveInteger(request.place_version, "place_version") : void 0;
|
|
5527
6363
|
const localPlaceFile = typeof request.local_place_file === "string" ? request.local_place_file : void 0;
|
|
5528
6364
|
if (launchSource === "published_place" && placeId !== void 0 && this._isLatestPublishedPlaceOpen(placeId)) {
|
|
5529
6365
|
return this._textResult({
|
|
@@ -5531,7 +6367,7 @@ ${code}`
|
|
|
5531
6367
|
message: `place_id ${placeId} is already connected. Use the existing instance or launch a specific place_revision.`
|
|
5532
6368
|
});
|
|
5533
6369
|
}
|
|
5534
|
-
const universeId = launchSource === "published_place" || launchSource === "place_revision" ?
|
|
6370
|
+
const universeId = launchSource === "published_place" || launchSource === "place_revision" ? await this._deriveUniverseId(placeId) : void 0;
|
|
5535
6371
|
const waitForConnection = request.wait_for_connection !== false;
|
|
5536
6372
|
const timeoutMs = this._optionalPositiveInteger(request.timeout_ms, "timeout_ms") ?? 12e4;
|
|
5537
6373
|
const beforeKeys = new Set(this.bridge.getPublicInstances().map((instance) => this._publicInstanceKey(instance)));
|
|
@@ -5797,26 +6633,40 @@ ${code}`
|
|
|
5797
6633
|
}
|
|
5798
6634
|
return hasServer && clientCount >= 2;
|
|
5799
6635
|
}
|
|
5800
|
-
async _waitForMultiplayerStart(instanceId, clientCount, timeoutSec = 30) {
|
|
6636
|
+
async _waitForMultiplayerStart(instanceId, clientCount, timeoutSec = 30, connectedAfter) {
|
|
5801
6637
|
const deadline = Date.now() + timeoutSec * 1e3;
|
|
6638
|
+
let lastPhase;
|
|
5802
6639
|
while (Date.now() < deadline) {
|
|
5803
6640
|
const exact = await this._waitForExactClientCount(instanceId, clientCount, 0.25, 0);
|
|
5804
6641
|
if (exact.ok || exact.extraClients) {
|
|
6642
|
+
if (exact.ok && connectedAfter !== void 0) {
|
|
6643
|
+
const instances = this.bridge.getInstances().filter((inst) => inst.instanceId === instanceId);
|
|
6644
|
+
const freshRoles = new Set(instances.filter((inst) => inst.connectedAt >= connectedAfter).map((inst) => inst.role));
|
|
6645
|
+
const freshClientCount = [...freshRoles].filter((role) => /^client-\d+$/.test(role)).length;
|
|
6646
|
+
if (!freshRoles.has("server") || freshClientCount !== clientCount) {
|
|
6647
|
+
await sleep(250);
|
|
6648
|
+
continue;
|
|
6649
|
+
}
|
|
6650
|
+
}
|
|
5805
6651
|
return { ok: exact.ok, roles: exact.roles, timedOut: false, error: exact.extraClients ? `Expected ${clientCount} client(s), but Studio registered ${exact.clientCount}.` : void 0 };
|
|
5806
6652
|
}
|
|
5807
6653
|
try {
|
|
5808
|
-
const
|
|
6654
|
+
const remainingMs = Math.max(1, Math.min(1e3, deadline - Date.now()));
|
|
6655
|
+
const editState = await this.client.request("/api/multiplayer-test-state", {}, instanceId, "edit", remainingMs);
|
|
5809
6656
|
const session = editState?.session;
|
|
5810
|
-
if (
|
|
6657
|
+
if (typeof session?.phase === "string") {
|
|
6658
|
+
lastPhase = session.phase;
|
|
6659
|
+
}
|
|
6660
|
+
if (session?.phase === "failed") {
|
|
5811
6661
|
return { ok: false, roles: this._rolesForInstance(instanceId), timedOut: false, phase: session.phase, error: session.error };
|
|
5812
6662
|
}
|
|
5813
6663
|
} catch {
|
|
5814
6664
|
}
|
|
5815
6665
|
await sleep(250);
|
|
5816
6666
|
}
|
|
5817
|
-
return { ok: false, roles: this._rolesForInstance(instanceId), timedOut: true };
|
|
6667
|
+
return { ok: false, roles: this._rolesForInstance(instanceId), timedOut: true, phase: lastPhase };
|
|
5818
6668
|
}
|
|
5819
|
-
async multiplayerPlaytest(action, numPlayers, target, testArgs, value, timeout, instance_id) {
|
|
6669
|
+
async multiplayerPlaytest(action, numPlayers, target, testArgs, value, timeout, instance_id, force) {
|
|
5820
6670
|
if (action !== "start" && action !== "status" && action !== "add_players" && action !== "leave_client" && action !== "end") {
|
|
5821
6671
|
throw new Error("multiplayer_playtest requires action=start|status|add_players|leave_client|end");
|
|
5822
6672
|
}
|
|
@@ -5838,93 +6688,130 @@ ${code}`
|
|
|
5838
6688
|
});
|
|
5839
6689
|
}
|
|
5840
6690
|
if (action === "start") {
|
|
5841
|
-
|
|
5842
|
-
|
|
5843
|
-
|
|
5844
|
-
|
|
6691
|
+
if (force !== true) {
|
|
6692
|
+
return this._textResult({
|
|
6693
|
+
success: false,
|
|
6694
|
+
action,
|
|
6695
|
+
error: "multiplayer_force_required",
|
|
6696
|
+
message: MULTIPLAYER_FORCE_REQUIRED_MESSAGE,
|
|
6697
|
+
requiresForce: true,
|
|
6698
|
+
manualCleanupRequired: true
|
|
6699
|
+
});
|
|
6700
|
+
}
|
|
6701
|
+
const body = this._parseTextResult(await this.multiplayerTestStart(numPlayers, testArgs, timeout, instance_id, force));
|
|
6702
|
+
const state = body.state && typeof body.state === "object" ? body.state : {};
|
|
6703
|
+
const launched = body.success === true && body.ready === true;
|
|
6704
|
+
return this._textResult(launched ? {
|
|
5845
6705
|
success: true,
|
|
5846
6706
|
action,
|
|
5847
|
-
message: "Multiplayer playtest started.",
|
|
5848
|
-
|
|
6707
|
+
message: "Multiplayer playtest started. Stop/end is disabled; close the multiplayer test windows manually when finished.",
|
|
6708
|
+
ready: true,
|
|
6709
|
+
manualCleanupRequired: true,
|
|
6710
|
+
roles: Array.isArray(body.roles) ? body.roles : void 0,
|
|
5849
6711
|
clientRoles: Array.isArray(state.clientRoles) ? state.clientRoles : void 0,
|
|
5850
6712
|
playerCount: typeof state.playerCount === "number" ? state.playerCount : void 0
|
|
5851
6713
|
} : {
|
|
5852
6714
|
success: false,
|
|
5853
6715
|
action,
|
|
5854
|
-
error:
|
|
5855
|
-
message:
|
|
5856
|
-
|
|
6716
|
+
error: body.error ?? body.wait?.error ?? "multiplayer_start_not_detected",
|
|
6717
|
+
message: body.success === true ? "Multiplayer playtest start was requested, but MCP did not detect the required server/client peers before timeout. You may need to close the test windows manually." : body.message ?? "Multiplayer playtest did not start.",
|
|
6718
|
+
manualCleanupRequired: body.startRequested === true || body.launched === true ? true : void 0,
|
|
6719
|
+
roles: Array.isArray(body.roles) ? body.roles : void 0
|
|
5857
6720
|
});
|
|
5858
6721
|
}
|
|
5859
6722
|
if (action === "add_players") {
|
|
5860
|
-
const
|
|
5861
|
-
const state =
|
|
5862
|
-
const success =
|
|
6723
|
+
const body = this._parseTextResult(await this.multiplayerTestAddPlayers(numPlayers, timeout, instance_id));
|
|
6724
|
+
const state = body.state && typeof body.state === "object" ? body.state : {};
|
|
6725
|
+
const success = body.success === true && body.ready === true;
|
|
5863
6726
|
return this._textResult(success ? {
|
|
5864
6727
|
success: true,
|
|
5865
6728
|
action,
|
|
5866
6729
|
message: "Players added.",
|
|
5867
|
-
roles: Array.isArray(
|
|
6730
|
+
roles: Array.isArray(body.roles) ? body.roles : void 0,
|
|
5868
6731
|
clientRoles: Array.isArray(state.clientRoles) ? state.clientRoles : void 0,
|
|
5869
6732
|
playerCount: typeof state.playerCount === "number" ? state.playerCount : void 0
|
|
5870
6733
|
} : {
|
|
5871
6734
|
success: false,
|
|
5872
6735
|
action,
|
|
5873
|
-
error:
|
|
5874
|
-
message:
|
|
5875
|
-
roles: Array.isArray(
|
|
6736
|
+
error: body.error ?? "add_players_failed",
|
|
6737
|
+
message: body.success === true ? "Players did not finish joining before timeout." : body.message ?? "Players were not added.",
|
|
6738
|
+
roles: Array.isArray(body.roles) ? body.roles : void 0
|
|
5876
6739
|
});
|
|
5877
6740
|
}
|
|
5878
6741
|
if (action === "leave_client") {
|
|
5879
|
-
const
|
|
5880
|
-
return this._textResult(
|
|
6742
|
+
const body = this._parseTextResult(await this.multiplayerTestLeaveClient(target ?? "client-1", timeout, instance_id));
|
|
6743
|
+
return this._textResult(body.success === true && body.left === true ? {
|
|
5881
6744
|
success: true,
|
|
5882
6745
|
action,
|
|
5883
6746
|
message: "Client left.",
|
|
5884
|
-
roles: Array.isArray(
|
|
6747
|
+
roles: Array.isArray(body.roles) ? body.roles : void 0
|
|
5885
6748
|
} : {
|
|
5886
6749
|
success: false,
|
|
5887
6750
|
action,
|
|
5888
|
-
error:
|
|
5889
|
-
message:
|
|
5890
|
-
roles: Array.isArray(
|
|
6751
|
+
error: body.error ?? "leave_client_failed",
|
|
6752
|
+
message: body.message ?? "Client did not leave.",
|
|
6753
|
+
roles: Array.isArray(body.roles) ? body.roles : void 0
|
|
5891
6754
|
});
|
|
5892
6755
|
}
|
|
5893
|
-
|
|
5894
|
-
return this._textResult(body.success === true && body.ended === true ? {
|
|
5895
|
-
success: true,
|
|
5896
|
-
action,
|
|
5897
|
-
message: "Multiplayer playtest ended."
|
|
5898
|
-
} : {
|
|
5899
|
-
success: false,
|
|
6756
|
+
return this._textResult({
|
|
5900
6757
|
action,
|
|
5901
|
-
|
|
5902
|
-
message: body.message ?? "Multiplayer playtest did not end.",
|
|
5903
|
-
roles: Array.isArray(body.roles) ? body.roles : void 0,
|
|
5904
|
-
editDone: body.editDone === false ? false : void 0
|
|
6758
|
+
...multiplayerStopDisabledBody()
|
|
5905
6759
|
});
|
|
5906
6760
|
}
|
|
5907
|
-
async multiplayerTestStart(numPlayers, testArgs, timeout, instance_id) {
|
|
6761
|
+
async multiplayerTestStart(numPlayers, testArgs, timeout, instance_id, force) {
|
|
5908
6762
|
if (!Number.isInteger(numPlayers) || numPlayers < 1 || numPlayers > 8) {
|
|
5909
6763
|
throw new Error("numPlayers must be an integer from 1 to 8");
|
|
5910
6764
|
}
|
|
5911
6765
|
const editTarget = this._resolveSingleTarget("edit", instance_id);
|
|
6766
|
+
if (force !== true) {
|
|
6767
|
+
return this._textResult({
|
|
6768
|
+
success: false,
|
|
6769
|
+
error: "multiplayer_force_required",
|
|
6770
|
+
message: MULTIPLAYER_FORCE_REQUIRED_MESSAGE,
|
|
6771
|
+
requiresForce: true,
|
|
6772
|
+
manualCleanupRequired: true
|
|
6773
|
+
});
|
|
6774
|
+
}
|
|
6775
|
+
const existingRuntime = this._runtimeTargetsForEquivalentInstances(editTarget.instanceId);
|
|
6776
|
+
if (existingRuntime.length > 0) {
|
|
6777
|
+
const roles = this._rolesForEquivalentInstances(editTarget.instanceId);
|
|
6778
|
+
return this._textResult({
|
|
6779
|
+
success: false,
|
|
6780
|
+
error: "Multiplayer playtest already running.",
|
|
6781
|
+
message: "A Studio runtime is already connected for this place. Close the existing playtest windows manually before starting another multiplayer playtest.",
|
|
6782
|
+
ready: true,
|
|
6783
|
+
timedOut: false,
|
|
6784
|
+
roles,
|
|
6785
|
+
runtimeRoles: existingRuntime.map((target) => target.role),
|
|
6786
|
+
manualCleanupRequired: true
|
|
6787
|
+
});
|
|
6788
|
+
}
|
|
6789
|
+
const startedAt = Date.now();
|
|
5912
6790
|
const response = await this.client.request("/api/multiplayer-test-start", { numPlayers, testArgs: testArgs ?? {} }, editTarget.instanceId, editTarget.role);
|
|
5913
6791
|
if (response?.error) {
|
|
5914
6792
|
return { content: [{ type: "text", text: JSON.stringify(response) }] };
|
|
5915
6793
|
}
|
|
5916
|
-
const wait = await this._waitForMultiplayerStart(editTarget.instanceId, numPlayers, timeout ??
|
|
6794
|
+
const wait = await this._waitForMultiplayerStart(editTarget.instanceId, numPlayers, timeout ?? 60, startedAt);
|
|
6795
|
+
const launched = wait.ok;
|
|
5917
6796
|
const state = await this._buildMultiplayerState(editTarget.instanceId);
|
|
6797
|
+
const success = response.success === true && wait.ok;
|
|
5918
6798
|
return {
|
|
5919
6799
|
content: [{
|
|
5920
6800
|
type: "text",
|
|
5921
6801
|
text: JSON.stringify({
|
|
5922
6802
|
...response,
|
|
6803
|
+
success,
|
|
5923
6804
|
ready: wait.ok,
|
|
6805
|
+
launched,
|
|
6806
|
+
startRequested: response.success === true,
|
|
5924
6807
|
timedOut: wait.timedOut,
|
|
5925
6808
|
wait,
|
|
5926
6809
|
roles: wait.roles,
|
|
5927
|
-
state
|
|
6810
|
+
state,
|
|
6811
|
+
error: success ? void 0 : wait.error ?? "multiplayer_start_not_detected",
|
|
6812
|
+
message: success ? "Multiplayer Studio test started and runtime peers detected." : "Multiplayer Studio test start was requested, but MCP did not detect the required server/client peers before timeout. Close the multiplayer test windows manually if Studio launched them.",
|
|
6813
|
+
manualCleanupRequired: success || response.success === true ? true : void 0,
|
|
6814
|
+
startedAt
|
|
5928
6815
|
})
|
|
5929
6816
|
}]
|
|
5930
6817
|
};
|
|
@@ -5985,27 +6872,10 @@ ${code}`
|
|
|
5985
6872
|
};
|
|
5986
6873
|
}
|
|
5987
6874
|
async multiplayerTestEnd(value, timeout, instance_id) {
|
|
5988
|
-
|
|
5989
|
-
|
|
5990
|
-
|
|
5991
|
-
|
|
5992
|
-
}
|
|
5993
|
-
const editDone = await this._waitForMultiplayerEditDone(serverTarget.instanceId, timeout ?? 30);
|
|
5994
|
-
const wait = await this._waitForRuntimeRoles(serverTarget.instanceId, { noRuntime: true }, timeout ?? 30);
|
|
5995
|
-
const state = await this._buildMultiplayerState(serverTarget.instanceId);
|
|
5996
|
-
return {
|
|
5997
|
-
content: [{
|
|
5998
|
-
type: "text",
|
|
5999
|
-
text: JSON.stringify({
|
|
6000
|
-
...response,
|
|
6001
|
-
ended: wait.ok,
|
|
6002
|
-
editDone,
|
|
6003
|
-
timedOut: wait.timedOut,
|
|
6004
|
-
roles: wait.roles,
|
|
6005
|
-
state
|
|
6006
|
-
})
|
|
6007
|
-
}]
|
|
6008
|
-
};
|
|
6875
|
+
void value;
|
|
6876
|
+
void timeout;
|
|
6877
|
+
void instance_id;
|
|
6878
|
+
return this._textResult(multiplayerStopDisabledBody());
|
|
6009
6879
|
}
|
|
6010
6880
|
async getConnectedInstances() {
|
|
6011
6881
|
const instances = this.bridge.getPublicInstances();
|
|
@@ -6041,14 +6911,14 @@ ${code}`
|
|
|
6041
6911
|
};
|
|
6042
6912
|
}
|
|
6043
6913
|
static findProjectRoot(startDir) {
|
|
6044
|
-
let dir =
|
|
6914
|
+
let dir = path4.resolve(startDir);
|
|
6045
6915
|
let previous = "";
|
|
6046
6916
|
while (dir !== previous) {
|
|
6047
|
-
if (
|
|
6917
|
+
if (fs3.existsSync(path4.join(dir, ".git")) || fs3.existsSync(path4.join(dir, "package.json"))) {
|
|
6048
6918
|
return dir;
|
|
6049
6919
|
}
|
|
6050
6920
|
previous = dir;
|
|
6051
|
-
dir =
|
|
6921
|
+
dir = path4.dirname(dir);
|
|
6052
6922
|
}
|
|
6053
6923
|
return null;
|
|
6054
6924
|
}
|
|
@@ -6056,15 +6926,15 @@ ${code}`
|
|
|
6056
6926
|
if (!candidate)
|
|
6057
6927
|
return false;
|
|
6058
6928
|
try {
|
|
6059
|
-
return
|
|
6929
|
+
return fs3.statSync(candidate).isDirectory();
|
|
6060
6930
|
} catch {
|
|
6061
6931
|
return false;
|
|
6062
6932
|
}
|
|
6063
6933
|
}
|
|
6064
6934
|
static ensureWritableDirectory(candidate, label) {
|
|
6065
|
-
const resolved =
|
|
6935
|
+
const resolved = path4.resolve(candidate);
|
|
6066
6936
|
try {
|
|
6067
|
-
|
|
6937
|
+
fs3.mkdirSync(resolved, { recursive: true });
|
|
6068
6938
|
} catch (error) {
|
|
6069
6939
|
throw new Error(`Unable to create ${label} build-library directory at ${resolved}: ${error.message}`);
|
|
6070
6940
|
}
|
|
@@ -6072,7 +6942,7 @@ ${code}`
|
|
|
6072
6942
|
throw new Error(`${label} build-library path is not a directory: ${resolved}`);
|
|
6073
6943
|
}
|
|
6074
6944
|
try {
|
|
6075
|
-
|
|
6945
|
+
fs3.accessSync(resolved, fs3.constants.W_OK);
|
|
6076
6946
|
} catch (error) {
|
|
6077
6947
|
throw new Error(`${label} build-library directory is not writable: ${resolved}. ${error.message}`);
|
|
6078
6948
|
}
|
|
@@ -6083,25 +6953,25 @@ ${code}`
|
|
|
6083
6953
|
if (_RobloxStudioTools._cachedLibraryPath)
|
|
6084
6954
|
return _RobloxStudioTools._cachedLibraryPath;
|
|
6085
6955
|
const overridePath = process.env.ROBLOXSTUDIO_MCP_BUILD_LIBRARY || process.env.BUILD_LIBRARY_PATH;
|
|
6086
|
-
const cwd =
|
|
6956
|
+
const cwd = path4.resolve(process.cwd());
|
|
6087
6957
|
const projectRoot = _RobloxStudioTools.findProjectRoot(cwd);
|
|
6088
|
-
const homeLibraryPath =
|
|
6089
|
-
const projectLibraryPath = projectRoot ?
|
|
6090
|
-
const cwdLibraryPath =
|
|
6958
|
+
const homeLibraryPath = path4.join(os3.homedir(), ".robloxstudio-mcp", "build-library");
|
|
6959
|
+
const projectLibraryPath = projectRoot ? path4.join(projectRoot, "build-library") : null;
|
|
6960
|
+
const cwdLibraryPath = path4.join(cwd, "build-library");
|
|
6091
6961
|
let result;
|
|
6092
6962
|
if (overridePath) {
|
|
6093
6963
|
result = _RobloxStudioTools.ensureWritableDirectory(overridePath, "override");
|
|
6094
6964
|
} else {
|
|
6095
6965
|
const existing = [projectLibraryPath, cwdLibraryPath].find((c) => c && _RobloxStudioTools.isDirectory(c) && (() => {
|
|
6096
6966
|
try {
|
|
6097
|
-
|
|
6967
|
+
fs3.accessSync(c, fs3.constants.W_OK);
|
|
6098
6968
|
return true;
|
|
6099
6969
|
} catch {
|
|
6100
6970
|
return false;
|
|
6101
6971
|
}
|
|
6102
6972
|
})());
|
|
6103
6973
|
if (existing) {
|
|
6104
|
-
result =
|
|
6974
|
+
result = path4.resolve(existing);
|
|
6105
6975
|
} else if (projectLibraryPath) {
|
|
6106
6976
|
try {
|
|
6107
6977
|
result = _RobloxStudioTools.ensureWritableDirectory(projectLibraryPath, "project-root");
|
|
@@ -6128,12 +6998,12 @@ ${code}`
|
|
|
6128
6998
|
if (response && response.success && response.buildData) {
|
|
6129
6999
|
const buildData = response.buildData;
|
|
6130
7000
|
const buildId = buildData.id || `${style}/exported`;
|
|
6131
|
-
const filePath =
|
|
6132
|
-
const dirPath =
|
|
6133
|
-
if (!
|
|
6134
|
-
|
|
7001
|
+
const filePath = path4.join(_RobloxStudioTools.findLibraryPath(), `${buildId}.json`);
|
|
7002
|
+
const dirPath = path4.dirname(filePath);
|
|
7003
|
+
if (!fs3.existsSync(dirPath)) {
|
|
7004
|
+
fs3.mkdirSync(dirPath, { recursive: true });
|
|
6135
7005
|
}
|
|
6136
|
-
|
|
7006
|
+
fs3.writeFileSync(filePath, JSON.stringify(buildData, null, 2));
|
|
6137
7007
|
response.savedTo = filePath;
|
|
6138
7008
|
}
|
|
6139
7009
|
return {
|
|
@@ -6230,12 +7100,12 @@ ${code}`
|
|
|
6230
7100
|
const normalizedParts = this.normalizeBuildParts(parts, new Set(Object.keys(normalizedPalette)));
|
|
6231
7101
|
const computedBounds = bounds || this.computeBounds(normalizedParts);
|
|
6232
7102
|
const buildData = { id, style, bounds: computedBounds, palette: normalizedPalette, parts: normalizedParts };
|
|
6233
|
-
const filePath =
|
|
6234
|
-
const dirPath =
|
|
6235
|
-
if (!
|
|
6236
|
-
|
|
7103
|
+
const filePath = path4.join(_RobloxStudioTools.findLibraryPath(), `${id}.json`);
|
|
7104
|
+
const dirPath = path4.dirname(filePath);
|
|
7105
|
+
if (!fs3.existsSync(dirPath)) {
|
|
7106
|
+
fs3.mkdirSync(dirPath, { recursive: true });
|
|
6237
7107
|
}
|
|
6238
|
-
|
|
7108
|
+
fs3.writeFileSync(filePath, JSON.stringify(buildData, null, 2));
|
|
6239
7109
|
return {
|
|
6240
7110
|
content: [
|
|
6241
7111
|
{
|
|
@@ -6289,12 +7159,12 @@ ${code}`
|
|
|
6289
7159
|
};
|
|
6290
7160
|
if (seed !== void 0)
|
|
6291
7161
|
buildData.generatorSeed = seed;
|
|
6292
|
-
const filePath =
|
|
6293
|
-
const dirPath =
|
|
6294
|
-
if (!
|
|
6295
|
-
|
|
7162
|
+
const filePath = path4.join(_RobloxStudioTools.findLibraryPath(), `${id}.json`);
|
|
7163
|
+
const dirPath = path4.dirname(filePath);
|
|
7164
|
+
if (!fs3.existsSync(dirPath)) {
|
|
7165
|
+
fs3.mkdirSync(dirPath, { recursive: true });
|
|
6296
7166
|
}
|
|
6297
|
-
|
|
7167
|
+
fs3.writeFileSync(filePath, JSON.stringify(buildData, null, 2));
|
|
6298
7168
|
return {
|
|
6299
7169
|
content: [
|
|
6300
7170
|
{
|
|
@@ -6318,17 +7188,17 @@ ${code}`
|
|
|
6318
7188
|
}
|
|
6319
7189
|
let resolved;
|
|
6320
7190
|
if (typeof buildData === "string") {
|
|
6321
|
-
const filePath =
|
|
6322
|
-
if (!
|
|
7191
|
+
const filePath = path4.join(_RobloxStudioTools.findLibraryPath(), `${buildData}.json`);
|
|
7192
|
+
if (!fs3.existsSync(filePath)) {
|
|
6323
7193
|
throw new Error(`Build not found in library: ${buildData}`);
|
|
6324
7194
|
}
|
|
6325
|
-
resolved = JSON.parse(
|
|
7195
|
+
resolved = JSON.parse(fs3.readFileSync(filePath, "utf-8"));
|
|
6326
7196
|
} else if (buildData.id && !buildData.parts) {
|
|
6327
|
-
const filePath =
|
|
6328
|
-
if (!
|
|
7197
|
+
const filePath = path4.join(_RobloxStudioTools.findLibraryPath(), `${buildData.id}.json`);
|
|
7198
|
+
if (!fs3.existsSync(filePath)) {
|
|
6329
7199
|
throw new Error(`Build not found in library: ${buildData.id}`);
|
|
6330
7200
|
}
|
|
6331
|
-
resolved = JSON.parse(
|
|
7201
|
+
resolved = JSON.parse(fs3.readFileSync(filePath, "utf-8"));
|
|
6332
7202
|
} else {
|
|
6333
7203
|
resolved = buildData;
|
|
6334
7204
|
}
|
|
@@ -6351,13 +7221,13 @@ ${code}`
|
|
|
6351
7221
|
const styles = style ? [style] : ["medieval", "modern", "nature", "scifi", "misc"];
|
|
6352
7222
|
const builds = [];
|
|
6353
7223
|
for (const s of styles) {
|
|
6354
|
-
const dirPath =
|
|
6355
|
-
if (!
|
|
7224
|
+
const dirPath = path4.join(libraryPath, s);
|
|
7225
|
+
if (!fs3.existsSync(dirPath))
|
|
6356
7226
|
continue;
|
|
6357
|
-
const files =
|
|
7227
|
+
const files = fs3.readdirSync(dirPath).filter((f) => f.endsWith(".json"));
|
|
6358
7228
|
for (const file of files) {
|
|
6359
7229
|
try {
|
|
6360
|
-
const content =
|
|
7230
|
+
const content = fs3.readFileSync(path4.join(dirPath, file), "utf-8");
|
|
6361
7231
|
const data = JSON.parse(content);
|
|
6362
7232
|
builds.push({
|
|
6363
7233
|
id: data.id || `${s}/${file.replace(".json", "")}`,
|
|
@@ -6396,11 +7266,11 @@ ${code}`
|
|
|
6396
7266
|
if (!id) {
|
|
6397
7267
|
throw new Error("Build ID is required for get_build");
|
|
6398
7268
|
}
|
|
6399
|
-
const filePath =
|
|
6400
|
-
if (!
|
|
7269
|
+
const filePath = path4.join(_RobloxStudioTools.findLibraryPath(), `${id}.json`);
|
|
7270
|
+
if (!fs3.existsSync(filePath)) {
|
|
6401
7271
|
throw new Error(`Build not found in library: ${id}`);
|
|
6402
7272
|
}
|
|
6403
|
-
const data = JSON.parse(
|
|
7273
|
+
const data = JSON.parse(fs3.readFileSync(filePath, "utf-8"));
|
|
6404
7274
|
const result = {
|
|
6405
7275
|
id: data.id,
|
|
6406
7276
|
style: data.style,
|
|
@@ -6483,11 +7353,11 @@ ${code}`
|
|
|
6483
7353
|
if (!buildId) {
|
|
6484
7354
|
throw new Error(`Invalid ${validatedKeyPath}: model key "${modelKey}" is not defined in sceneData.models`);
|
|
6485
7355
|
}
|
|
6486
|
-
const filePath =
|
|
6487
|
-
if (!
|
|
7356
|
+
const filePath = path4.join(libraryPath, `${buildId}.json`);
|
|
7357
|
+
if (!fs3.existsSync(filePath)) {
|
|
6488
7358
|
throw new Error(`Build not found in library: ${buildId}`);
|
|
6489
7359
|
}
|
|
6490
|
-
const content =
|
|
7360
|
+
const content = fs3.readFileSync(filePath, "utf-8");
|
|
6491
7361
|
const buildData = JSON.parse(content);
|
|
6492
7362
|
const buildName = buildId.split("/").pop() || buildId;
|
|
6493
7363
|
expandedBuilds.push({
|
|
@@ -6827,11 +7697,11 @@ ${code}`
|
|
|
6827
7697
|
return this.resolveUploadedReferenceImageId(decalId, instance_id);
|
|
6828
7698
|
}
|
|
6829
7699
|
async uploadAsset(filePath, assetType, displayName, description, userId, groupId) {
|
|
6830
|
-
if (!
|
|
7700
|
+
if (!fs3.existsSync(filePath)) {
|
|
6831
7701
|
throw new Error(`File not found: ${filePath}`);
|
|
6832
7702
|
}
|
|
6833
|
-
const fileContent =
|
|
6834
|
-
const fileName =
|
|
7703
|
+
const fileContent = fs3.readFileSync(filePath);
|
|
7704
|
+
const fileName = path4.basename(filePath);
|
|
6835
7705
|
if (assetType === "Decal" && this.cookieClient.hasCookie()) {
|
|
6836
7706
|
const result2 = await this.cookieClient.uploadDecal(fileContent, displayName, description || "");
|
|
6837
7707
|
return {
|
|
@@ -7056,10 +7926,10 @@ ${code}`
|
|
|
7056
7926
|
return { content: [{ type: "text", text: JSON.stringify({ error: "plugin returned no base64 payload" }) }] };
|
|
7057
7927
|
}
|
|
7058
7928
|
const bytes = Buffer.from(response.base64, "base64");
|
|
7059
|
-
const resolved =
|
|
7929
|
+
const resolved = path4.resolve(outputPath);
|
|
7060
7930
|
try {
|
|
7061
|
-
|
|
7062
|
-
|
|
7931
|
+
fs3.mkdirSync(path4.dirname(resolved), { recursive: true });
|
|
7932
|
+
fs3.writeFileSync(resolved, bytes);
|
|
7063
7933
|
} catch (err) {
|
|
7064
7934
|
return { content: [{ type: "text", text: JSON.stringify({ error: `failed to write ${resolved}: ${err.message}` }) }] };
|
|
7065
7935
|
}
|
|
@@ -7092,9 +7962,9 @@ ${code}`
|
|
|
7092
7962
|
let bytes;
|
|
7093
7963
|
let sourceLabel;
|
|
7094
7964
|
if (source.path !== void 0) {
|
|
7095
|
-
const resolved =
|
|
7965
|
+
const resolved = path4.resolve(source.path);
|
|
7096
7966
|
try {
|
|
7097
|
-
bytes =
|
|
7967
|
+
bytes = fs3.readFileSync(resolved);
|
|
7098
7968
|
} catch (err) {
|
|
7099
7969
|
return { content: [{ type: "text", text: JSON.stringify({ error: `failed to read ${resolved}: ${err.message}` }) }] };
|
|
7100
7970
|
}
|
|
@@ -7264,6 +8134,24 @@ var init_proxy_bridge_service = __esm({
|
|
|
7264
8134
|
getInstances() {
|
|
7265
8135
|
return this.cachedInstances;
|
|
7266
8136
|
}
|
|
8137
|
+
async unregisterInstanceIdEverywhere(instanceId) {
|
|
8138
|
+
const response = await fetch(`${this.primaryBaseUrl}/unregister-instance-id`, {
|
|
8139
|
+
method: "POST",
|
|
8140
|
+
headers: { "Content-Type": "application/json" },
|
|
8141
|
+
body: JSON.stringify({ instanceId })
|
|
8142
|
+
});
|
|
8143
|
+
if (!response.ok) {
|
|
8144
|
+
const body = await response.text().catch(() => "");
|
|
8145
|
+
throw new Error(`Proxy unregister failed (${response.status}): ${body || response.statusText}`);
|
|
8146
|
+
}
|
|
8147
|
+
const result = await response.json();
|
|
8148
|
+
const removed = Array.isArray(result.removed) ? result.removed : [];
|
|
8149
|
+
const removedKeys = new Set(removed.map((inst) => `${inst.instanceId}\0${inst.role}`));
|
|
8150
|
+
if (removedKeys.size > 0) {
|
|
8151
|
+
this.cachedInstances = this.cachedInstances.filter((inst) => !removedKeys.has(`${inst.instanceId}\0${inst.role}`));
|
|
8152
|
+
}
|
|
8153
|
+
return removed;
|
|
8154
|
+
}
|
|
7267
8155
|
/** Called when this proxy is being discarded (e.g. promotion to primary
|
|
7268
8156
|
replaced it). Stops the background refresh so it doesn't leak. */
|
|
7269
8157
|
stop() {
|
|
@@ -7317,7 +8205,7 @@ var init_proxy_bridge_service = __esm({
|
|
|
7317
8205
|
// ../core/dist/server.js
|
|
7318
8206
|
import { Server as Server2 } from "@modelcontextprotocol/sdk/server/index.js";
|
|
7319
8207
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
7320
|
-
import { CallToolRequestSchema as CallToolRequestSchema2, ErrorCode as
|
|
8208
|
+
import { CallToolRequestSchema as CallToolRequestSchema2, ErrorCode as ErrorCode3, ListToolsRequestSchema as ListToolsRequestSchema2, McpError as McpError3 } from "@modelcontextprotocol/sdk/types.js";
|
|
7321
8209
|
var RobloxStudioMCPServer;
|
|
7322
8210
|
var init_server = __esm({
|
|
7323
8211
|
"../core/dist/server.js"() {
|
|
@@ -7326,6 +8214,7 @@ var init_server = __esm({
|
|
|
7326
8214
|
init_tools();
|
|
7327
8215
|
init_bridge_service();
|
|
7328
8216
|
init_proxy_bridge_service();
|
|
8217
|
+
init_mcp_compat();
|
|
7329
8218
|
RobloxStudioMCPServer = class {
|
|
7330
8219
|
server;
|
|
7331
8220
|
tools;
|
|
@@ -7345,6 +8234,7 @@ var init_server = __esm({
|
|
|
7345
8234
|
});
|
|
7346
8235
|
this.bridge = new BridgeService();
|
|
7347
8236
|
this.tools = new RobloxStudioTools(this.bridge);
|
|
8237
|
+
registerResourceHandlers(this.server);
|
|
7348
8238
|
this.setupToolHandlers();
|
|
7349
8239
|
}
|
|
7350
8240
|
setupToolHandlers() {
|
|
@@ -7360,11 +8250,11 @@ var init_server = __esm({
|
|
|
7360
8250
|
this.server.setRequestHandler(CallToolRequestSchema2, async (request) => {
|
|
7361
8251
|
const { name, arguments: args } = request.params;
|
|
7362
8252
|
if (!this.allowedToolNames.has(name)) {
|
|
7363
|
-
throw new
|
|
8253
|
+
throw new McpError3(ErrorCode3.MethodNotFound, `Unknown tool: ${name}`);
|
|
7364
8254
|
}
|
|
7365
8255
|
const handler = TOOL_HANDLERS[name];
|
|
7366
8256
|
if (!handler) {
|
|
7367
|
-
throw new
|
|
8257
|
+
throw new McpError3(ErrorCode3.MethodNotFound, `Unknown tool: ${name}`);
|
|
7368
8258
|
}
|
|
7369
8259
|
try {
|
|
7370
8260
|
return await handler(this.tools, args ?? {});
|
|
@@ -7382,9 +8272,9 @@ var init_server = __esm({
|
|
|
7382
8272
|
isError: true
|
|
7383
8273
|
};
|
|
7384
8274
|
}
|
|
7385
|
-
if (error instanceof
|
|
8275
|
+
if (error instanceof McpError3)
|
|
7386
8276
|
throw error;
|
|
7387
|
-
throw new
|
|
8277
|
+
throw new McpError3(ErrorCode3.InternalError, `Tool execution failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
7388
8278
|
}
|
|
7389
8279
|
});
|
|
7390
8280
|
}
|
|
@@ -8049,7 +8939,7 @@ var init_definitions = __esm({
|
|
|
8049
8939
|
{
|
|
8050
8940
|
name: "get_script_source",
|
|
8051
8941
|
category: "read",
|
|
8052
|
-
description: 'Get script source. Returns "source" and "numberedSource" (line-numbered).
|
|
8942
|
+
description: 'Get script source. Returns "source" and "numberedSource" (line-numbered). Pass line_range for large scripts; without a range, large scripts are truncated (see the "truncated" flag and "note") to avoid flooding the context.',
|
|
8053
8943
|
inputSchema: {
|
|
8054
8944
|
type: "object",
|
|
8055
8945
|
properties: {
|
|
@@ -8057,13 +8947,9 @@ var init_definitions = __esm({
|
|
|
8057
8947
|
type: "string",
|
|
8058
8948
|
description: "Canonical path to a LuaSourceContainer"
|
|
8059
8949
|
},
|
|
8060
|
-
|
|
8061
|
-
type: "
|
|
8062
|
-
description: "
|
|
8063
|
-
},
|
|
8064
|
-
endLine: {
|
|
8065
|
-
type: "number",
|
|
8066
|
-
description: "End line (inclusive)"
|
|
8950
|
+
line_range: {
|
|
8951
|
+
type: "string",
|
|
8952
|
+
description: 'Line range to return: "start-end" (e.g. "100-200"), open-ended ("100-" or "-200"), or a single line ("42").'
|
|
8067
8953
|
},
|
|
8068
8954
|
instance_id: {
|
|
8069
8955
|
type: "string",
|
|
@@ -8099,7 +8985,7 @@ var init_definitions = __esm({
|
|
|
8099
8985
|
{
|
|
8100
8986
|
name: "edit_script_lines",
|
|
8101
8987
|
category: "write",
|
|
8102
|
-
description:
|
|
8988
|
+
description: 'Replace exact text in a script. Without line_range, old_string must match exactly once in the script. Pass line_range as a single line (e.g. "42") to anchor the edit when old_string is ambiguous.',
|
|
8103
8989
|
inputSchema: {
|
|
8104
8990
|
type: "object",
|
|
8105
8991
|
properties: {
|
|
@@ -8109,15 +8995,15 @@ var init_definitions = __esm({
|
|
|
8109
8995
|
},
|
|
8110
8996
|
old_string: {
|
|
8111
8997
|
type: "string",
|
|
8112
|
-
description: "Exact text to find and replace. Must be unique in the script unless
|
|
8998
|
+
description: "Exact text to find and replace. Must be unique in the script unless line_range is provided."
|
|
8113
8999
|
},
|
|
8114
9000
|
new_string: {
|
|
8115
9001
|
type: "string",
|
|
8116
9002
|
description: "Replacement text"
|
|
8117
9003
|
},
|
|
8118
|
-
|
|
8119
|
-
type: "
|
|
8120
|
-
description:
|
|
9004
|
+
line_range: {
|
|
9005
|
+
type: "string",
|
|
9006
|
+
description: 'Optional single line where old_string begins, such as "42". When provided, skips uniqueness check and requires old_string to match starting at that exact line.'
|
|
8121
9007
|
},
|
|
8122
9008
|
instance_id: {
|
|
8123
9009
|
type: "string",
|
|
@@ -8157,7 +9043,7 @@ var init_definitions = __esm({
|
|
|
8157
9043
|
{
|
|
8158
9044
|
name: "delete_script_lines",
|
|
8159
9045
|
category: "write",
|
|
8160
|
-
description: "Delete a range of lines. 1-indexed
|
|
9046
|
+
description: "Delete a range of lines. line_range is 1-indexed and inclusive.",
|
|
8161
9047
|
inputSchema: {
|
|
8162
9048
|
type: "object",
|
|
8163
9049
|
properties: {
|
|
@@ -8165,20 +9051,16 @@ var init_definitions = __esm({
|
|
|
8165
9051
|
type: "string",
|
|
8166
9052
|
description: "Canonical path to a LuaSourceContainer"
|
|
8167
9053
|
},
|
|
8168
|
-
|
|
8169
|
-
type: "
|
|
8170
|
-
description: "
|
|
8171
|
-
},
|
|
8172
|
-
endLine: {
|
|
8173
|
-
type: "number",
|
|
8174
|
-
description: "End line (inclusive)"
|
|
9054
|
+
line_range: {
|
|
9055
|
+
type: "string",
|
|
9056
|
+
description: 'Line range to delete: "start-end" (e.g. "100-200") or a single line ("42"). Open-ended ranges are not accepted for deletion.'
|
|
8175
9057
|
},
|
|
8176
9058
|
instance_id: {
|
|
8177
9059
|
type: "string",
|
|
8178
9060
|
description: "Which connected Studio place to target. Required when multiple places are connected; omit when one. Use get_connected_instances to list available IDs."
|
|
8179
9061
|
}
|
|
8180
9062
|
},
|
|
8181
|
-
required: ["instancePath", "
|
|
9063
|
+
required: ["instancePath", "line_range"]
|
|
8182
9064
|
}
|
|
8183
9065
|
},
|
|
8184
9066
|
// === Attributes ===
|
|
@@ -8424,21 +9306,21 @@ var init_definitions = __esm({
|
|
|
8424
9306
|
{
|
|
8425
9307
|
name: "grep_scripts",
|
|
8426
9308
|
category: "read",
|
|
8427
|
-
description:
|
|
9309
|
+
description: 'Ripgrep-inspired search across all script sources. Supports literal and Lua pattern matching (with top-level "|" alternation), context lines, early termination, and results grouped by script with line/column numbers.',
|
|
8428
9310
|
inputSchema: {
|
|
8429
9311
|
type: "object",
|
|
8430
9312
|
properties: {
|
|
8431
9313
|
pattern: {
|
|
8432
9314
|
type: "string",
|
|
8433
|
-
description:
|
|
9315
|
+
description: 'Search text. Literal by default; with usePattern=true it is a case-sensitive Lua pattern with top-level "|" alternation (e.g. "foo|bar").'
|
|
8434
9316
|
},
|
|
8435
9317
|
caseSensitive: {
|
|
8436
9318
|
type: "boolean",
|
|
8437
|
-
description: "
|
|
9319
|
+
description: "Literal search case sensitivity (default: false). Lua pattern mode is always case-sensitive; passing false with usePattern=true is rejected."
|
|
8438
9320
|
},
|
|
8439
9321
|
usePattern: {
|
|
8440
9322
|
type: "boolean",
|
|
8441
|
-
description:
|
|
9323
|
+
description: 'Use case-sensitive Lua pattern matching instead of literal search (default: false). Supports top-level alternation: "a|b" matches a line containing "a" or "b". Note: Lua patterns are NOT PCRE \u2014 use %d/%a/%w classes and ".-" (not ".*?"); ^ $ ( ) . % + - * ? [ ] are magic.'
|
|
8442
9324
|
},
|
|
8443
9325
|
contextLines: {
|
|
8444
9326
|
type: "number",
|
|
@@ -8477,7 +9359,7 @@ var init_definitions = __esm({
|
|
|
8477
9359
|
{
|
|
8478
9360
|
name: "manage_instance",
|
|
8479
9361
|
category: "write",
|
|
8480
|
-
description: 'Launch, close, inspect, and find revisions for Studio instances. Use action="list_place_versions" with place_id to retrieve version numbers through Open Cloud asset versions, then action="launch" with source="place_revision" and place_version to open an older revision. action="close" can close an MCP-managed instance or an explicitly connected edit instance by instance_id. action="launch" source="published_place" opens the latest published place and is blocked if that place_id is already connected; source="place_revision" is allowed because Studio opens explicit past revisions as anonymous local copies. Requires ROBLOX_OPEN_CLOUD_API_KEY with asset:read for list_place_versions.',
|
|
9362
|
+
description: 'Launch, close, inspect, and find revisions for Studio instances. Use action="launch" with source="baseplate" for a blank place, or source="local_file" with local_place_file for a local place; neither uses place_id. Use action="list_place_versions" with place_id to retrieve version numbers through Open Cloud asset versions, then action="launch" with source="place_revision", place_id, and place_version to open an older revision. action="close" can close an MCP-managed instance or an explicitly connected edit instance by instance_id. action="launch" source="published_place" opens the latest published place and is blocked if that place_id is already connected; source="place_revision" is allowed because Studio opens explicit past revisions as anonymous local copies. Requires ROBLOX_OPEN_CLOUD_API_KEY with asset:read for list_place_versions.',
|
|
8481
9363
|
inputSchema: {
|
|
8482
9364
|
type: "object",
|
|
8483
9365
|
properties: {
|
|
@@ -8489,7 +9371,7 @@ var init_definitions = __esm({
|
|
|
8489
9371
|
source: {
|
|
8490
9372
|
type: "string",
|
|
8491
9373
|
enum: ["baseplate", "local_file", "published_place", "place_revision"],
|
|
8492
|
-
description: 'Required for action="launch". published_place opens the latest place; place_revision opens a specific older version as an anonymous local copy.'
|
|
9374
|
+
description: 'Required for action="launch". baseplate/local_file do not use place_id; published_place opens the latest place; place_revision opens a specific older version as an anonymous local copy.'
|
|
8493
9375
|
},
|
|
8494
9376
|
local_place_file: {
|
|
8495
9377
|
type: "string",
|
|
@@ -8497,11 +9379,7 @@ var init_definitions = __esm({
|
|
|
8497
9379
|
},
|
|
8498
9380
|
place_id: {
|
|
8499
9381
|
type: "number",
|
|
8500
|
-
description: '
|
|
8501
|
-
},
|
|
8502
|
-
universe_id: {
|
|
8503
|
-
type: "number",
|
|
8504
|
-
description: "Optional for published_place/place_revision launches; derived from place_id when omitted."
|
|
9382
|
+
description: 'Only used for source="published_place", source="place_revision", and action="list_place_versions". Do not pass for source="baseplate" or source="local_file".'
|
|
8505
9383
|
},
|
|
8506
9384
|
place_version: {
|
|
8507
9385
|
type: "number",
|
|
@@ -8838,7 +9716,7 @@ var init_definitions = __esm({
|
|
|
8838
9716
|
{
|
|
8839
9717
|
name: "multiplayer_playtest",
|
|
8840
9718
|
category: "write",
|
|
8841
|
-
description: 'Start
|
|
9719
|
+
description: 'Start or inspect a StudioTestService multiplayer playtest. Use action="start" with numPlayers and force=true only when you accept that MCP cannot stop it and you must manually close the multiplayer test windows afterward. action="status" inspects state, action="add_players" adds players, and action="leave_client" removes one client. action="end" is disabled for now and returns the StudioTestService:EndTest broken-API reason. Returns brief lifecycle status only; read script output with get_runtime_logs.',
|
|
8842
9720
|
inputSchema: {
|
|
8843
9721
|
type: "object",
|
|
8844
9722
|
properties: {
|
|
@@ -8859,11 +9737,15 @@ var init_definitions = __esm({
|
|
|
8859
9737
|
description: 'For action="start": JSON-compatible table passed to StudioTestService:GetTestArgs() on server and clients.'
|
|
8860
9738
|
},
|
|
8861
9739
|
value: {
|
|
8862
|
-
description: '
|
|
9740
|
+
description: 'Ignored while action="end" is disabled.'
|
|
9741
|
+
},
|
|
9742
|
+
force: {
|
|
9743
|
+
type: "boolean",
|
|
9744
|
+
description: 'Required for action="start". Pass true only if you understand StudioTestService:EndTest is broken in this flow and you will manually close the multiplayer test windows.'
|
|
8863
9745
|
},
|
|
8864
9746
|
timeout: {
|
|
8865
9747
|
type: "number",
|
|
8866
|
-
description: "Max seconds to wait for action completion. Defaults to 30."
|
|
9748
|
+
description: "Max seconds to wait for start peer detection or action completion. Defaults to 30."
|
|
8867
9749
|
},
|
|
8868
9750
|
instance_id: {
|
|
8869
9751
|
type: "string",
|
|
@@ -10098,6 +10980,31 @@ part(0,2,0,2,1,1,"b")`,
|
|
|
10098
10980
|
},
|
|
10099
10981
|
required: ["pattern", "replacement"]
|
|
10100
10982
|
}
|
|
10983
|
+
},
|
|
10984
|
+
// === Documentation ===
|
|
10985
|
+
{
|
|
10986
|
+
name: "get_roblox_docs",
|
|
10987
|
+
category: "read",
|
|
10988
|
+
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.',
|
|
10989
|
+
inputSchema: {
|
|
10990
|
+
type: "object",
|
|
10991
|
+
properties: {
|
|
10992
|
+
name: {
|
|
10993
|
+
type: "string",
|
|
10994
|
+
description: 'Exact PascalCase name of the class, enum, datatype, or library (e.g. "ProximityPrompt", "KeyCode", "CFrame", "table")'
|
|
10995
|
+
},
|
|
10996
|
+
doc_type: {
|
|
10997
|
+
type: "string",
|
|
10998
|
+
enum: ["classes", "enums", "datatypes", "libraries", "globals"],
|
|
10999
|
+
description: "Documentation category (default: classes)"
|
|
11000
|
+
},
|
|
11001
|
+
section: {
|
|
11002
|
+
type: "string",
|
|
11003
|
+
description: 'Optional "##"-level section to return instead of the whole page (e.g. "Description", "Properties", "Methods", "Events", "Code Samples")'
|
|
11004
|
+
}
|
|
11005
|
+
},
|
|
11006
|
+
required: ["name"]
|
|
11007
|
+
}
|
|
10101
11008
|
}
|
|
10102
11009
|
];
|
|
10103
11010
|
DEPRECATED_TOOL_DEFINITIONS = [
|
|
@@ -10143,7 +11050,7 @@ part(0,2,0,2,1,1,"b")`,
|
|
|
10143
11050
|
{
|
|
10144
11051
|
name: "multiplayer_test_start",
|
|
10145
11052
|
category: "write",
|
|
10146
|
-
description: 'Deprecated. Use multiplayer_playtest with action="start" instead. Starts a StudioTestService multiplayer test.',
|
|
11053
|
+
description: 'Deprecated. Use multiplayer_playtest with action="start" instead. Starts a StudioTestService multiplayer test only when force=true acknowledges that MCP cannot stop it and the test windows must be closed manually.',
|
|
10147
11054
|
inputSchema: {
|
|
10148
11055
|
type: "object",
|
|
10149
11056
|
properties: {
|
|
@@ -10154,6 +11061,10 @@ part(0,2,0,2,1,1,"b")`,
|
|
|
10154
11061
|
testArgs: {
|
|
10155
11062
|
description: "JSON-compatible table passed to StudioTestService:GetTestArgs() on server and clients."
|
|
10156
11063
|
},
|
|
11064
|
+
force: {
|
|
11065
|
+
type: "boolean",
|
|
11066
|
+
description: "Required. Pass true only if you understand StudioTestService:EndTest is broken in this flow and you will manually close the multiplayer test windows."
|
|
11067
|
+
},
|
|
10157
11068
|
timeout: {
|
|
10158
11069
|
type: "number",
|
|
10159
11070
|
description: "Max seconds to wait for server + clients to register (default 30)."
|
|
@@ -10163,7 +11074,7 @@ part(0,2,0,2,1,1,"b")`,
|
|
|
10163
11074
|
description: "Which connected Studio place to target. Required when multiple places are connected; omit when one. Use get_connected_instances to list available IDs."
|
|
10164
11075
|
}
|
|
10165
11076
|
},
|
|
10166
|
-
required: ["numPlayers"]
|
|
11077
|
+
required: ["numPlayers", "force"]
|
|
10167
11078
|
}
|
|
10168
11079
|
},
|
|
10169
11080
|
{
|
|
@@ -10228,16 +11139,16 @@ part(0,2,0,2,1,1,"b")`,
|
|
|
10228
11139
|
{
|
|
10229
11140
|
name: "multiplayer_test_end",
|
|
10230
11141
|
category: "write",
|
|
10231
|
-
description:
|
|
11142
|
+
description: "Deprecated. Multiplayer StudioTestService stop/end is disabled for now because StudioTestService:EndTest is broken in this flow. This tool returns a disabled error and does not call EndTest.",
|
|
10232
11143
|
inputSchema: {
|
|
10233
11144
|
type: "object",
|
|
10234
11145
|
properties: {
|
|
10235
11146
|
value: {
|
|
10236
|
-
description: "
|
|
11147
|
+
description: "Ignored while multiplayer stop/end is disabled."
|
|
10237
11148
|
},
|
|
10238
11149
|
timeout: {
|
|
10239
11150
|
type: "number",
|
|
10240
|
-
description: "
|
|
11151
|
+
description: "Ignored while multiplayer stop/end is disabled."
|
|
10241
11152
|
},
|
|
10242
11153
|
instance_id: {
|
|
10243
11154
|
type: "string",
|
|
@@ -10253,15 +11164,15 @@ part(0,2,0,2,1,1,"b")`,
|
|
|
10253
11164
|
});
|
|
10254
11165
|
|
|
10255
11166
|
// ../core/dist/install-plugin-helpers.js
|
|
10256
|
-
import { existsSync as existsSync4, readFileSync as
|
|
11167
|
+
import { existsSync as existsSync4, readFileSync as readFileSync5, unlinkSync } from "fs";
|
|
10257
11168
|
import { execSync } from "child_process";
|
|
10258
|
-
import { join as
|
|
10259
|
-
import { homedir as
|
|
11169
|
+
import { join as join4 } from "path";
|
|
11170
|
+
import { homedir as homedir4 } from "os";
|
|
10260
11171
|
function isWSL() {
|
|
10261
11172
|
if (process.platform !== "linux")
|
|
10262
11173
|
return false;
|
|
10263
11174
|
try {
|
|
10264
|
-
const v =
|
|
11175
|
+
const v = readFileSync5("/proc/version", "utf8");
|
|
10265
11176
|
return /microsoft|wsl/i.test(v);
|
|
10266
11177
|
} catch {
|
|
10267
11178
|
return false;
|
|
@@ -10279,7 +11190,7 @@ function getWindowsUserPluginsDir() {
|
|
|
10279
11190
|
}).toString().trim();
|
|
10280
11191
|
if (!linuxPath)
|
|
10281
11192
|
return null;
|
|
10282
|
-
return
|
|
11193
|
+
return join4(linuxPath, "Roblox", "Plugins");
|
|
10283
11194
|
} catch {
|
|
10284
11195
|
return null;
|
|
10285
11196
|
}
|
|
@@ -10288,7 +11199,7 @@ function getPluginsFolder() {
|
|
|
10288
11199
|
if (process.env.MCP_PLUGINS_DIR)
|
|
10289
11200
|
return process.env.MCP_PLUGINS_DIR;
|
|
10290
11201
|
if (process.platform === "win32") {
|
|
10291
|
-
return
|
|
11202
|
+
return join4(process.env.LOCALAPPDATA || join4(homedir4(), "AppData", "Local"), "Roblox", "Plugins");
|
|
10292
11203
|
}
|
|
10293
11204
|
if (isWSL()) {
|
|
10294
11205
|
const win = getWindowsUserPluginsDir();
|
|
@@ -10296,10 +11207,10 @@ function getPluginsFolder() {
|
|
|
10296
11207
|
return win;
|
|
10297
11208
|
console.warn("[install-plugin] WSL detected but could not resolve Windows %LOCALAPPDATA%. Falling back to ~/Documents/Roblox/Plugins/ - you will likely need to copy the rbxmx to /mnt/c/Users/<you>/AppData/Local/Roblox/Plugins/ manually. Set MCP_PLUGINS_DIR to skip detection.");
|
|
10298
11209
|
}
|
|
10299
|
-
return
|
|
11210
|
+
return join4(homedir4(), "Documents", "Roblox", "Plugins");
|
|
10300
11211
|
}
|
|
10301
11212
|
function handleVariantConflict({ pluginsFolder, otherAssetName, replace, log = console.log, warn = console.warn }) {
|
|
10302
|
-
const otherDest =
|
|
11213
|
+
const otherDest = join4(pluginsFolder, otherAssetName);
|
|
10303
11214
|
if (!existsSync4(otherDest))
|
|
10304
11215
|
return;
|
|
10305
11216
|
if (replace) {
|
|
@@ -10345,8 +11256,8 @@ __export(install_plugin_exports, {
|
|
|
10345
11256
|
installBundledPlugin: () => installBundledPlugin,
|
|
10346
11257
|
installPlugin: () => installPlugin
|
|
10347
11258
|
});
|
|
10348
|
-
import { copyFileSync as copyFileSync2, createWriteStream, existsSync as existsSync5, mkdirSync as
|
|
10349
|
-
import { dirname as dirname3, join as
|
|
11259
|
+
import { copyFileSync as copyFileSync2, createWriteStream, existsSync as existsSync5, mkdirSync as mkdirSync4, readFileSync as readFileSync6, unlinkSync as unlinkSync2 } from "fs";
|
|
11260
|
+
import { dirname as dirname3, join as join5 } from "path";
|
|
10350
11261
|
import { fileURLToPath } from "url";
|
|
10351
11262
|
import { get } from "https";
|
|
10352
11263
|
function httpsGet(url) {
|
|
@@ -10407,7 +11318,7 @@ function prepareInstall({
|
|
|
10407
11318
|
}) {
|
|
10408
11319
|
const pluginsFolder = getPluginsFolder();
|
|
10409
11320
|
if (!existsSync5(pluginsFolder)) {
|
|
10410
|
-
|
|
11321
|
+
mkdirSync4(pluginsFolder, { recursive: true });
|
|
10411
11322
|
}
|
|
10412
11323
|
handleVariantConflict({
|
|
10413
11324
|
pluginsFolder,
|
|
@@ -10421,21 +11332,21 @@ function prepareInstall({
|
|
|
10421
11332
|
function bundledAssetPath() {
|
|
10422
11333
|
const currentDir = dirname3(fileURLToPath(import.meta.url));
|
|
10423
11334
|
const candidates = [
|
|
10424
|
-
|
|
10425
|
-
|
|
11335
|
+
join5(currentDir, "..", "studio-plugin", ASSET_NAME),
|
|
11336
|
+
join5(currentDir, "..", "..", "..", "studio-plugin", ASSET_NAME)
|
|
10426
11337
|
];
|
|
10427
11338
|
return candidates.find((candidate) => existsSync5(candidate)) ?? null;
|
|
10428
11339
|
}
|
|
10429
11340
|
function packageVersion() {
|
|
10430
11341
|
const currentDir = dirname3(fileURLToPath(import.meta.url));
|
|
10431
|
-
const pkg = JSON.parse(
|
|
11342
|
+
const pkg = JSON.parse(readFileSync6(join5(currentDir, "..", "package.json"), "utf8"));
|
|
10432
11343
|
if (!pkg.version) {
|
|
10433
11344
|
throw new Error("Package version not found");
|
|
10434
11345
|
}
|
|
10435
11346
|
return pkg.version;
|
|
10436
11347
|
}
|
|
10437
11348
|
function bundledPluginVersion(source) {
|
|
10438
|
-
const match =
|
|
11349
|
+
const match = readFileSync6(source, "utf8").match(/local CURRENT_VERSION = "([^"]+)"/);
|
|
10439
11350
|
return match ? match[1] : null;
|
|
10440
11351
|
}
|
|
10441
11352
|
function assertBundledPluginVersion(source) {
|
|
@@ -10449,8 +11360,8 @@ function assertBundledPluginVersion(source) {
|
|
|
10449
11360
|
}
|
|
10450
11361
|
function filesMatch(a, b) {
|
|
10451
11362
|
if (!existsSync5(b)) return false;
|
|
10452
|
-
const aBytes =
|
|
10453
|
-
const bBytes =
|
|
11363
|
+
const aBytes = readFileSync6(a);
|
|
11364
|
+
const bBytes = readFileSync6(b);
|
|
10454
11365
|
return aBytes.length === bBytes.length && aBytes.equals(bBytes);
|
|
10455
11366
|
}
|
|
10456
11367
|
async function installBundledPlugin(options = {}) {
|
|
@@ -10463,7 +11374,7 @@ async function installBundledPlugin(options = {}) {
|
|
|
10463
11374
|
}
|
|
10464
11375
|
assertBundledPluginVersion(source);
|
|
10465
11376
|
const pluginsFolder = prepareInstall({ replaceVariant, log, warn });
|
|
10466
|
-
const dest =
|
|
11377
|
+
const dest = join5(pluginsFolder, ASSET_NAME);
|
|
10467
11378
|
if (filesMatch(source, dest)) return;
|
|
10468
11379
|
copyFileSync2(source, dest);
|
|
10469
11380
|
log(`Installed ${ASSET_NAME} to ${dest}`);
|
|
@@ -10476,7 +11387,7 @@ async function installPlugin(options = {}) {
|
|
|
10476
11387
|
const bundled = bundledAssetPath();
|
|
10477
11388
|
if (bundled) {
|
|
10478
11389
|
assertBundledPluginVersion(bundled);
|
|
10479
|
-
const dest2 =
|
|
11390
|
+
const dest2 = join5(pluginsFolder, ASSET_NAME);
|
|
10480
11391
|
if (filesMatch(bundled, dest2)) {
|
|
10481
11392
|
log(`${ASSET_NAME} already installed.`);
|
|
10482
11393
|
return;
|
|
@@ -10491,7 +11402,7 @@ async function installPlugin(options = {}) {
|
|
|
10491
11402
|
if (!asset) {
|
|
10492
11403
|
throw new Error(`${ASSET_NAME} not found in release ${release.tag_name}`);
|
|
10493
11404
|
}
|
|
10494
|
-
const dest =
|
|
11405
|
+
const dest = join5(pluginsFolder, ASSET_NAME);
|
|
10495
11406
|
log(`Downloading ${ASSET_NAME} from ${release.tag_name}...`);
|
|
10496
11407
|
await download(asset.browser_download_url, dest);
|
|
10497
11408
|
log(`Installed to ${dest}`);
|