UNPKG

@minecraft/creator-tools

Version:

Minecraft Creator Tools command line and libraries.

1,059 lines 230 kB
"use strict"; var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || (function () { var ownKeys = function(o) { ownKeys = Object.getOwnPropertyNames || function (o) { var ar = []; for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; return ar; }; return ownKeys(o); }; return function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); __setModuleDefault(result, mod); return result; }; })(); var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); const mcp_js_1 = require("@modelcontextprotocol/sdk/server/mcp.js"); const stdio_js_1 = require("@modelcontextprotocol/sdk/server/stdio.js"); const zod_1 = require("zod"); const streamableHttp_js_1 = require("@modelcontextprotocol/sdk/server/streamableHttp.js"); const crypto_1 = require("crypto"); const DedicatedServer_1 = require("./DedicatedServer"); const MinecraftUtilities_1 = __importDefault(require("../minecraft/MinecraftUtilities")); const Log_1 = __importDefault(require("../core/Log")); const HttpUtilities_1 = __importDefault(require("./HttpUtilities")); const Database_1 = __importDefault(require("../minecraft/Database")); const DataFormZod_1 = __importDefault(require("../dataform/DataFormZod")); const ServerManager_1 = __importDefault(require("./ServerManager")); const ICreatorToolsData_1 = require("../app/ICreatorToolsData"); const WorldLevelDat_1 = require("../minecraft/WorldLevelDat"); const Status_1 = require("../app/Status"); const Package_1 = __importDefault(require("../app/Package")); const Utilities_1 = __importDefault(require("../core/Utilities")); const Project_1 = __importDefault(require("../app/Project")); const ProjectExporter_1 = __importDefault(require("../app/ProjectExporter")); const ProjectUtilities_1 = __importDefault(require("../app/ProjectUtilities")); const ProjectItemCreateManager_1 = __importDefault(require("../app/ProjectItemCreateManager")); const ClUtils_1 = __importDefault(require("../cli/ClUtils")); const CommandContextFactory_1 = require("../cli/core/CommandContextFactory"); const CreatorToolsHost_1 = __importDefault(require("../app/CreatorToolsHost")); const StorageUtilities_1 = __importDefault(require("../storage/StorageUtilities")); const ModelDesignUtilities_1 = __importDefault(require("../minecraft/ModelDesignUtilities")); const ModelDesignTemplates_1 = require("../minecraft/ModelDesignTemplates"); const StructureUtilities_1 = __importDefault(require("../minecraft/StructureUtilities")); const ContentMetaSchemaZod_1 = require("../minecraft/ContentMetaSchemaZod"); const ContentGenerator_1 = require("../minecraft/ContentGenerator"); const ContentSchemaInferrer_1 = __importDefault(require("../minecraft/ContentSchemaInferrer")); const PlaywrightPageRenderer_1 = __importDefault(require("./PlaywrightPageRenderer")); const ImageGenerationUtilities_1 = __importDefault(require("./ImageGenerationUtilities")); const ServerManager_2 = require("./ServerManager"); const NodeStorage_1 = __importDefault(require("./NodeStorage")); const ModelDesignDefinition_1 = __importDefault(require("../design/ModelDesignDefinition")); const StructureDesignDefinition_1 = __importDefault(require("../design/StructureDesignDefinition")); const IProjectItemData_1 = require("../app/IProjectItemData"); const toolcommands_1 = require("../app/toolcommands"); const registerNodeCommands_1 = require("../app/toolcommands/registerNodeCommands"); const fs = __importStar(require("fs")); const path = __importStar(require("path")); const net = __importStar(require("net")); const pngjs_1 = require("pngjs"); const LocalUtilities_1 = require("./LocalUtilities"); class MinecraftMcpServer { /** Starting port for the internal HTTP server range */ static PORT_RANGE_START = 6136; /** Ending port for the internal HTTP server range (200 ports available) */ static PORT_RANGE_END = 6336; /** Maximum attempts to find an available port before giving up */ static PORT_MAX_ATTEMPTS = 20; _server; _env = undefined; /** Single HTTP transport instance. Created once in startHttp() and reused for all requests. */ _httpTransport = undefined; _creatorTools = undefined; _serverManager; /** Cache for loaded MCP preferences, keyed by folder path where prefs.json was found */ _mcpPrefsCache = {}; /** Folders we've already checked and found no prefs.json (negative cache) */ _mcpPrefsNotFoundFolders = new Set(); /** HTTP server port for model preview rendering (dynamically assigned on startup) */ _previewServerPort = MinecraftMcpServer.PORT_RANGE_START; /** * Cached PlaywrightPageRenderer for reuse across preview operations. * * TODO: The renderer init/health-check/reinit boilerplate is duplicated ~4 times * in this file (preview_model, preview_volume, preview_structure, screenshot). * Extract into a shared `ensureRendererReady(baseUrl, httpServer)` method. */ _cachedRenderer; /** Flag to prevent multiple cleanup calls */ _cleaningUp = false; /** * Maps user-facing session names to BDS slot numbers. * The "default" session is auto-registered when `mct serve` starts BDS on slot 0. * Additional sessions can be created via createMinecraftSessionWithContent or * connected to existing slots via connectToMinecraftSession. */ _sessions = {}; /** * Working folder path for MCP operations. * When set via the -i argument, this folder is used as the default context for * file operations and is exposed to AI assistants via the MCP protocol's prompts. */ _workingFolder = undefined; /** Getter for the working folder */ get workingFolder() { return this._workingFolder; } constructor() { this._server = new mcp_js_1.McpServer({ name: "minecraft-creator-tools", version: "1.0.0", }); this._processValidateContent = this._processValidateContent.bind(this); this._processValidateContentAtPath = this._processValidateContentAtPath.bind(this); this._runActionSet = this._runActionSet.bind(this); this._moveSessionPlayerToLocation = this._moveSessionPlayerToLocation.bind(this); this._sessionOp = this._sessionOp.bind(this); this._runActionSetOp = this._runActionSetOp.bind(this); this._createOp = this._createOp.bind(this); this._addOp = this._addOp.bind(this); this._createMinecraftSession = this._createMinecraftSession.bind(this); this._runCommandOp = this._runCommandOp.bind(this); this._readImageFileOp = this._readImageFileOp.bind(this); this._writeImageFileFromBase64Op = this._writeImageFileFromBase64Op.bind(this); this._writeImageFileFromSvgOp = this._writeImageFileFromSvgOp.bind(this); this._writeImageFileFromPixelArtOp = this._writeImageFileFromPixelArtOp.bind(this); this._designModelOp = this._designModelOp.bind(this); this._designStructureOp = this._designStructureOp.bind(this); this._createMinecraftContentOp = this._createMinecraftContentOp.bind(this); this._getEffectiveContentSchemaOp = this._getEffectiveContentSchemaOp.bind(this); this._listMinecraftSessionsOp = this._listMinecraftSessionsOp.bind(this); this._connectToMinecraftSessionOp = this._connectToMinecraftSessionOp.bind(this); } /** * Wrapper for McpServer.registerTool that avoids TS2589 "Type instantiation is * excessively deep and possibly infinite" caused by the SDK's complex generic * inference on ToolCallback<InputArgs>. Casts the callback to `any` to break the * recursive type chain while preserving runtime behavior. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any _registerTool(name, config, cb) { this._server.registerTool(name, config, cb); } /** * Ensures a ServerManager instance is available, creating one if needed. * If a ServerManager was provided externally (e.g., from HttpServer in `mct serve` mode), * it will be reused. Otherwise, a new one is created with the "mcp" slot prefix * to isolate MCP servers from other contexts. */ ensureServerManager() { if (!this._env || !this._creatorTools) { throw new Error("Creator Tools is not initialized"); } if (!this._serverManager) { this._serverManager = new ServerManager_1.default(this._env, this._creatorTools); // Use "mcp" prefix to isolate MCP server slots from other contexts (serve, vscode) this._serverManager.slotPrefix = "mcp"; } return this._serverManager; } /** * Resolve a session name to a slot number. * - If sessionName is empty/undefined or "default", returns slot 0 (the default session). * - Otherwise, looks up the session in the registered sessions map. * - Throws if the session name is not found. */ _resolveSlot(sessionName) { if (!sessionName || sessionName === "default") { return 0; } const info = this._sessions[sessionName]; if (info !== undefined) { return info.slot; } throw new Error(`Unknown session "${sessionName}". Use listMinecraftSessions to discover sessions or connectToMinecraftSession to register one.`); } /** * Handle an incoming HTTP request by delegating to the single transport. * * Architecture: We use one StreamableHTTPServerTransport created in startHttp() * and connected once to the McpServer. The transport handles session management, * initialization, and request routing internally. This avoids the SDK limitation * where registerCapabilities() cannot be called after connect(). * * For POST requests, we pre-parse the body since Node's http.IncomingMessage * doesn't auto-parse JSON (unlike Express). The parsed body is passed to * transport.handleRequest() so it doesn't try to re-parse. */ async handleRequest(req, res) { if (!this._httpTransport) { this.sendErrorRequest(503, "MCP server transport not initialized", req, res); return; } if (req.method === "POST") { const body = []; req.on("data", (chunk) => { body.push(chunk); }); req.on("end", async () => { try { if (body.length < 1) { this.sendErrorRequest(400, "Empty request body", req, res); return; } // Parse body as JSON before passing to the transport. // The MCP SDK expects a parsed object, not a raw Buffer. // (Express does this automatically via express.json() middleware; // we must do it manually with Node's raw http server.) let parsedBody; try { parsedBody = JSON.parse(Buffer.concat(body).toString()); } catch { this.sendErrorRequest(400, "Invalid JSON in request body", req, res); return; } await this._httpTransport.handleRequest(req, res, parsedBody); } catch (e) { Log_1.default.debug("Error handling MCP POST request: " + (e?.message || e)); if (!res.headersSent) { this.sendErrorRequest(500, "Internal server error processing MCP request", req, res); } } }); } else if (req.method === "GET" || req.method === "DELETE") { // GET (SSE streams) and DELETE (session termination) are forwarded directly // to the transport which handles session validation internally. try { await this._httpTransport.handleRequest(req, res); } catch (e) { Log_1.default.debug("Error handling MCP " + req.method + " request: " + (e?.message || e)); if (!res.headersSent) { this.sendErrorRequest(500, "Internal server error processing MCP request", req, res); } } } else { this.sendErrorRequest(405, "Method not allowed", req, res); } } sendErrorRequest(statusCode, message, req, res) { Log_1.default.message(HttpUtilities_1.default.getShortReqDescription(req) + "Error request: " + message); if (!res.headersSent) { res.writeHead(statusCode); } res.end(message); } /** * Finds MCP preferences for a given file path by looking for .mct/mcp/prefs.json * in the file's parent folder and up to 11 levels of parent folders. * Results are cached per session to avoid redundant file system reads. * * @param filePath The absolute path to the file being accessed * @returns The MCP preferences if found, or undefined if no prefs.json exists` */ getMcpPrefsForPath(filePath) { const normalizedPath = path.normalize(filePath); let currentDir = path.dirname(normalizedPath); // Check up to 11 levels (current parent + 10 ancestors) for (let i = 0; i < 10; i++) { // Check if we've already found prefs for this folder if (this._mcpPrefsCache[currentDir]) { return this._mcpPrefsCache[currentDir]; } // Check if we've already determined there are no prefs in this folder if (this._mcpPrefsNotFoundFolders.has(currentDir)) { // Continue to parent folder const parentDir = path.dirname(currentDir); if (parentDir === currentDir) { // Reached root break; } currentDir = parentDir; continue; } // Look for .mct/mcp/prefs.json in this folder const prefsPath = path.join(currentDir, ".mct", "mcp", "prefs.json"); if (fs.existsSync(prefsPath)) { try { const prefsContent = fs.readFileSync(prefsPath, "utf-8"); const prefs = JSON.parse(prefsContent); // Cache the prefs for this folder this._mcpPrefsCache[currentDir] = prefs; return prefs; } catch (error) { Log_1.default.debugAlert(`Failed to parse MCP prefs at ${prefsPath}: ${error}`); // Mark as not found so we don't keep trying to parse a malformed file this._mcpPrefsNotFoundFolders.add(currentDir); } } else { // Mark this folder as checked (no prefs found) this._mcpPrefsNotFoundFolders.add(currentDir); } // Move to parent folder const parentDir = path.dirname(currentDir); if (parentDir === currentDir) { // Reached root break; } currentDir = parentDir; } return undefined; } /** * Checks if a specific MCP preference flag is enabled for a given file path. * * @param filePath The absolute path to the file being accessed * @param prefKey The preference key to check * @returns true if the preference is explicitly set to true, false otherwise */ isMcpPrefEnabled(filePath, prefKey) { const prefs = this.getMcpPrefsForPath(filePath); if (!prefs) { return false; } return prefs[prefKey] === true; } /** * Validates that a file path is safe for MCP operations. * Ensures the path doesn't use traversal sequences and that the resolved path * stays within the directory tree authorized by the prefs.json file. */ _validateMcpFilePath(filePath) { if (!filePath) { return { valid: false, error: "File path is required." }; } // Reject null bytes if (filePath.includes("\0")) { return { valid: false, error: "File path contains invalid characters." }; } // Resolve to absolute and normalize const resolved = path.resolve(filePath); // Reject if the resolved path differs from the normalized input in a way that indicates traversal. // path.resolve handles ../ but we also explicitly reject the sequences. const normalized = path.normalize(filePath); if (normalized.includes("..")) { return { valid: false, error: "File path must not contain directory traversal sequences (..)." }; } // Reject symlinks on the parent directory to prevent symlink-based escapes const parentDir = path.dirname(resolved); try { if (fs.existsSync(parentDir)) { const realParent = fs.realpathSync(parentDir); if (realParent !== parentDir) { return { valid: false, error: "File path parent directory resolves through a symlink, which is not allowed.", }; } } } catch { // If we can't check, allow -- the subsequent fs operations will fail naturally } return { valid: true }; } /** * Validates an MCP file path and returns an error CallToolResult if invalid, * or undefined if the path is safe. Use as an early-return guard in MCP tool handlers. */ _checkMcpFilePath(filePath) { const pathCheck = this._validateMcpFilePath(filePath); if (!pathCheck.valid) { return { content: [{ type: "text", text: `Error: ${pathCheck.error}` }], isError: true, }; } return undefined; } async _processValidateContent(args) { if (!this._creatorTools) { throw new Error("Creator Tools is not initialized"); } const projectOrError = await this._creatorTools.createProjectFromContent(args.jsonContentOrBase64ZipContent); if (!projectOrError || typeof projectOrError === "string") { throw new Error("Failed to create project. Was the content a valid Base64-encoded ZIP file?" + (typeof projectOrError === "string" ? " Error: " + projectOrError : "")); } const pis = projectOrError.indevInfoSet; await pis.generateForProject(); const resultObject = pis.getDataObject(undefined, undefined, undefined); return { content: [{ type: "text", text: JSON.stringify(resultObject, null, 2) }], structuredContent: { info: resultObject }, }; } async _sessionOp(args) { return { content: [{ type: "text", text: "Successfully completed" }], }; } /** * Lists all Minecraft sessions — both registered named sessions and any * active BDS slots discovered via the ServerManager. */ async _listMinecraftSessionsOp() { if (!this._creatorTools || !this._env) { throw new Error("Creator Tools is not initialized"); } const serverManager = this.ensureServerManager(); const activeSlots = serverManager.getActiveSlots(); // Build a merged view: named sessions + any unnamed active slots const sessions = []; // Emit all named sessions for (const name of Object.keys(this._sessions)) { const info = this._sessions[name]; const slot = info.slot; const port = MinecraftUtilities_1.default.getPortForSlot(slot); const server = serverManager.getActiveServer(slot); const status = server ? DedicatedServer_1.DedicatedServerStatus[server.status] : "stopped"; sessions.push({ name, slot, port, status }); } // Discover active slots that have no name registered const namedSlots = new Set(Object.values(this._sessions).map((s) => s.slot)); for (const slot of activeSlots) { if (!namedSlots.has(slot)) { const port = MinecraftUtilities_1.default.getPortForSlot(slot); const server = serverManager.getActiveServer(slot); const status = server ? DedicatedServer_1.DedicatedServerStatus[server.status] : "stopped"; sessions.push({ name: `(unnamed slot ${slot})`, slot, port, status }); } } return { content: [{ type: "text", text: JSON.stringify(sessions, null, 2) }], }; } /** * Registers an existing BDS slot as a named session so that subsequent * tool calls can reference it by name. */ async _connectToMinecraftSessionOp(args) { if (!this._creatorTools || !this._env) { throw new Error("Creator Tools is not initialized"); } const slot = args.slot ?? 0; const serverManager = this.ensureServerManager(); const server = serverManager.getActiveServer(slot); if (!server) { return { content: [ { type: "text", text: `No active server found on slot ${slot} (port ${MinecraftUtilities_1.default.getPortForSlot(slot)}). ` + `Use listMinecraftSessions to see active slots, or createMinecraftSessionWithContent to start a new one.`, }, ], isError: true, }; } this._sessions[args.sessionName] = { slot, description: `Connected to existing slot ${slot}` }; const status = DedicatedServer_1.DedicatedServerStatus[server.status]; return { content: [ { type: "text", text: `Session "${args.sessionName}" registered on slot ${slot} (port ${MinecraftUtilities_1.default.getPortForSlot(slot)}, status: ${status}).`, }, ], }; } async _moveSessionPlayerToLocation(args) { const slot = this._resolveSlot(args.sessionName); let result = await this._runActionSet({ name: "Test Player Move Action Set", targetType: 1, actions: [ { type: "test_simulated_player_move", name: args.playerName, location: [ args.locationToHavePlayerMoveTo.x, args.locationToHavePlayerMoveTo.y, args.locationToHavePlayerMoveTo.z, ], }, ], }, slot); return { content: [{ type: "text", text: "Successfully moved player in session" }], structuredContent: { state: result ?? {} }, }; } async _createMinecraftSession(args) { if (!this._creatorTools || !this._env) { throw new Error("Creator Tools is not initialized"); } const serverManager = this.ensureServerManager(); await this._env.load(); await serverManager.prepare(); serverManager.ensureHttpServer(6128); // Find the next free slot. If slot 0 is already occupied by an active server, // pick the first unused slot so we don't clobber an existing session. const activeSlots = new Set(serverManager.getActiveSlots()); const usedSlots = new Set(Object.values(this._sessions).map((s) => s.slot)); let targetSlot = 0; while (activeSlots.has(targetSlot) || usedSlots.has(targetSlot)) { targetSlot++; if (targetSlot >= 80) { throw new Error("No free server slots available (all 80 are in use)."); } } const startMessage = { mode: ICreatorToolsData_1.DedicatedServerMode.auto, path: undefined, additionalContentPath: args.packagedMcaddonOrMcworldFilePath, forceStartNewWorld: true, // Always start with a fresh world for MCP sessions worldSettings: this.getWorldSettings(), transientWorld: true, // Mark as transient - world data is reset each deployment }; let srvr = await serverManager.ensureActiveServer(targetSlot, startMessage); if (srvr) { await srvr.startServer(false, startMessage); await srvr.waitUntilStarted(); } // Register the newly created session this._sessions[args.sessionName] = { slot: targetSlot, description: `Created with content` }; let result = await this._runActionSet({ name: "Test Player Spawn Action Set", targetType: 1, actions: [{ type: "test_simulated_player_spawn", name: args.testPlayerNameToUse, location: [0, 0, 0] }], }, targetSlot); const port = MinecraftUtilities_1.default.getPortForSlot(targetSlot); return { content: [ { type: "text", text: `Successfully started session "${args.sessionName}" on slot ${targetSlot} (port ${port}).`, }, ], structuredContent: { state: result ?? {} }, }; } async _runActionSet(actionSet, slot) { let actionSetStr = JSON.stringify(actionSet); actionSetStr = actionSetStr.replace(/\"/g, "|"); let token = Utilities_1.default.createRandomLowerId(6); let result = await this.runCommand('scriptevent mct:actionset "' + token + "|" + actionSetStr + '"', token + "|", slot); if (result) { let rasIndex = result.indexOf("ras|"); if (rasIndex) { let nextPipe = result.indexOf("|", rasIndex + 5); if (nextPipe >= 0) { const resultJsonStr = result.substring(nextPipe + 1); try { const resultObject = JSON.parse(resultJsonStr); return resultObject; } catch (e) { return undefined; } } } } return undefined; } getWorldSettings() { let gt = WorldLevelDat_1.GameType.survival; let generator = WorldLevelDat_1.Generator.infinite; let difficulty = WorldLevelDat_1.Difficulty.easy; let randomSeed = "2000"; const packRefs = []; Package_1.default.ensureMinecraftCreatorToolsPackageReference(packRefs); const worldSettings = { gameType: gt, generator: generator, cheatsEnabled: true, difficulty: difficulty, playerPermissionLevel: WorldLevelDat_1.PlayerPermissionsLevel.operator, permissionLevel: WorldLevelDat_1.PlayerPermissionsLevel.operator, randomSeed: randomSeed, packageReferences: packRefs, worldTemplateReferences: undefined, }; return worldSettings; } async _runActionSetOp(args) { if (!this._creatorTools || !this._env || !args.actionSet) { throw new Error("Creator Tools is not initialized"); } const slot = this._resolveSlot(args.sessionName); const serverManager = this.ensureServerManager(); let srvr = await serverManager.ensureActiveServer(slot); if (srvr) { await this._runActionSet(args.actionSet, slot); } return { content: [{ type: "text", text: "Successfully ran action set operation" }], }; } async _runCommandOp(args) { const slot = this._resolveSlot(args.sessionName); let result = await this.runCommand(args.command, undefined, slot); return { content: [{ type: "text", text: result ? result : "No result from command" }], }; } async runCommand(command, token, slot) { if (!this._creatorTools || !this._env) { throw new Error("Creator Tools is not initialized"); } const serverManager = this.ensureServerManager(); let srvr = await serverManager.ensureActiveServer(slot ?? 0); let result = undefined; if (srvr) { result = await srvr.runCommandImmediate(command, token); } return result; } async _processValidateContentAtPath(args) { if (!this._creatorTools) { throw new Error("Creator Tools is not initialized"); } const projectOrError = await this._creatorTools.createProjectFromPath(args.filePath); if (!projectOrError || typeof projectOrError === "string") { throw new Error("Failed to create project. Was the content a valid Base64-encoded ZIP file?" + (typeof projectOrError === "string" ? " Error: " + projectOrError : "")); } const pis = projectOrError.indevInfoSet; await pis.generateForProject(); const resultObject = pis.getDataObject(undefined, undefined, undefined); return { content: [{ type: "text", text: JSON.stringify(resultObject, null, 2) }], structuredContent: { info: resultObject }, }; } async _create(project, title, description, newName, creator, template) { if (!this._env || !this._creatorTools) { return; } await this._env.load(); if (!this._env.iAgreeToTheMinecraftEndUserLicenseAgreementAndPrivacyStatementAtMinecraftDotNetSlashEula) { Log_1.default.message("The Minecraft End User License Agreement and Privacy Statement was not agreed to."); return; } await this._creatorTools.loadGallery(); if (!this._creatorTools.gallery) { Log_1.default.message("Not configured correctly to create a project (no gallery)."); return; } const galProjects = this._creatorTools.gallery.items; let galProject; if (template) { for (let i = 0; i < galProjects.length; i++) { const galProjectCand = galProjects[i]; if (galProjectCand && galProjectCand.id && galProjectCand.id.toLowerCase() === template.toLowerCase()) { galProject = galProjectCand; } } } if (!newName) { Log_1.default.error("Not configured correctly to create a project."); return; } if (!galProject) { Log_1.default.error("No project was selected."); return; } project = await ProjectExporter_1.default.syncProjectFromGitHub(true, this._creatorTools, galProject.gitHubRepoName, galProject.gitHubOwner, galProject.gitHubBranch, galProject.gitHubFolder, newName, project, galProject.fileList, async (message) => { Log_1.default.message(message); }, true); let suggestedShortName = undefined; if (newName && creator) { suggestedShortName = ProjectUtilities_1.default.getSuggestedProjectShortName(creator, newName); } if (creator) { await ProjectUtilities_1.default.applyCreator(project, creator); } await ProjectUtilities_1.default.processNewProject(project, title, description, suggestedShortName, false); } async _add(project, templateType, newName) { if (!this._env || !this._creatorTools) { Log_1.default.error("Not configured correctly to create a project (no mctools core)."); return false; } await this._env.load(); if (!this._env.iAgreeToTheMinecraftEndUserLicenseAgreementAndPrivacyStatementAtMinecraftDotNetSlashEula) { Log_1.default.message("The Minecraft End User License Agreement and Privacy Statement was not agreed to."); return false; } await this._creatorTools.loadGallery(); if (!this._creatorTools.gallery) { Log_1.default.message("Not configured correctly to add an item (no gallery)."); return false; } if (templateType && newName) { for (const galItem of this._creatorTools.gallery.items) { if (galItem.id === templateType) { await ProjectItemCreateManager_1.default.addFromGallery(project, newName, galItem); await project.save(); return true; } } } return false; } async _createOp(args) { if (!this._creatorTools) { throw new Error("Creator Tools is not initialized"); } if (!fs.existsSync(args.folderPathToCreateProjectAt)) { fs.mkdirSync(args.folderPathToCreateProjectAt, { recursive: true }); } const project = ClUtils_1.default.createProject(this._creatorTools, { ctorProjectName: args.newName, localFolderPath: args.folderPathToCreateProjectAt, }); await this._create(project, args.title, args.description ? args.description : "", args.newName, args.creator, args.template); return { content: [{ type: "text", text: "Created! Additional files were added to your project." }], }; } async _addOp(args) { if (!this._creatorTools) { throw new Error("Creator Tools is not initialized"); } if (!fs.existsSync(args.folderPathToCreateProjectAt)) { fs.mkdirSync(args.folderPathToCreateProjectAt, { recursive: true }); } const project = ClUtils_1.default.createProject(this._creatorTools, { ctorProjectName: args.name, localFolderPath: args.folderPathToCreateProjectAt, }); // Load existing project structure so that addFromGallery places files into // the correct existing pack folders rather than creating new ones. await project.inferProjectItemsFromFiles(); let result = await this._add(project, args.templateType, args.name); if (!result) { return { content: [{ type: "text", text: "No items were added." }], }; } else { return { content: [{ type: "text", text: "Additional items were added." }], }; } } /** * Creates Minecraft content from a meta-schema definition. * This is a simplified, AI-friendly format that generates all required files. */ async _createMinecraftContentOp(args) { if (!this._creatorTools) { throw new Error("Creator Tools is not initialized"); } try { // Validate the definition const parseResult = ContentMetaSchemaZod_1.MinecraftContentSchema.safeParse(args.definition); if (!parseResult.success) { return { content: [ { type: "text", text: `Validation error: ${parseResult.error.errors .map((e) => `${e.path.join(".")}: ${e.message}`) .join(", ")}`, }, ], }; } // Generate the content const generator = new ContentGenerator_1.ContentGenerator(parseResult.data); const generated = await generator.generate(); // Resolve where artifacts should actually land. The user may have passed a // folder that's inside an existing project, a newly-created empty folder, or // a folder that already has unrelated content. See _resolveProjectRoot for // the heuristic. const resolved = MinecraftMcpServer._resolveProjectRoot(args.outputPath, { namespace: parseResult.data.namespace, displayName: parseResult.data.displayName, }); const projectRoot = resolved.root; // Ensure the resolved project root exists if (!fs.existsSync(projectRoot)) { fs.mkdirSync(projectRoot, { recursive: true }); } const namespace = parseResult.data.namespace || "custom"; // Detect existing pack folders so we can reuse them instead of creating duplicates. // If a project was previously created (e.g., via createProject), use the first existing // behavior/resource pack folder rather than creating a new namespace-based one. let bpBasePath = path.join(projectRoot, "behavior_packs", namespace); let rpBasePath = path.join(projectRoot, "resource_packs", namespace); const existingBpFolder = MinecraftMcpServer._findExistingPackFolder(path.join(projectRoot, "behavior_packs")); const existingRpFolder = MinecraftMcpServer._findExistingPackFolder(path.join(projectRoot, "resource_packs")); if (existingBpFolder) { bpBasePath = existingBpFolder; } if (existingRpFolder) { rpBasePath = existingRpFolder; } // Write generated files const filesWritten = []; // Helper to write files const writeFile = (file) => { let basePath = projectRoot; if (file.pack === "behavior") { basePath = bpBasePath; } else if (file.pack === "resource") { basePath = rpBasePath; } const fullPath = path.resolve(basePath, file.path); const resolvedOutputPath = path.resolve(projectRoot); // Prevent path traversal: ensure the resolved path stays within the output directory if (!fullPath.startsWith(resolvedOutputPath + path.sep) && fullPath !== resolvedOutputPath) { Log_1.default.error("Skipping file with path traversal outside output directory: " + file.path); return; } const dirPath = path.dirname(fullPath); if (!fs.existsSync(dirPath)) { fs.mkdirSync(dirPath, { recursive: true }); } // Do not overwrite files that already exist (e.g., files created by // designModel or manually by the user). Only write new files. if (fs.existsSync(fullPath)) { return; } if (file.type === "json") { fs.writeFileSync(fullPath, JSON.stringify(file.content, null, 2), "utf-8"); } else if (file.type === "text") { fs.writeFileSync(fullPath, file.content, "utf-8"); } else if (file.type === "png") { // Handle both Uint8Array (from ContentGenerator) and base64 string if (file.content instanceof Uint8Array) { fs.writeFileSync(fullPath, Buffer.from(file.content)); } else { fs.writeFileSync(fullPath, Buffer.from(file.content, "base64")); } } filesWritten.push(fullPath); }; // Write all generated files — but preserve existing manifest UUIDs // and merge texture atlas files rather than overwriting them. if (generated.behaviorPackManifest) { MinecraftMcpServer._writeManifestPreservingUuids(bpBasePath, generated.behaviorPackManifest, filesWritten); } if (generated.resourcePackManifest) { MinecraftMcpServer._writeManifestPreservingUuids(rpBasePath, generated.resourcePackManifest, filesWritten); } for (const file of generated.entityBehaviors) writeFile(file); for (const file of generated.entityResources) writeFile(file); for (const file of generated.blockBehaviors) writeFile(file); for (const file of generated.blockResources) writeFile(file); for (const file of generated.itemBehaviors) writeFile(file); for (const file of generated.itemResources) writeFile(file); for (const file of generated.lootTables) writeFile(file); for (const file of generated.recipes) writeFile(file); for (const file of generated.spawnRules) writeFile(file); for (const file of generated.features) writeFile(file); for (const file of generated.featureRules) writeFile(file); for (const file of generated.textures) writeFile(file); for (const file of generated.geometries) writeFile(file); for (const file of generated.renderControllers) writeFile(file); // Merge singleton resource pack files: these are pack-wide catalogs where // each MCP call should ADD entries rather than overwrite the entire file. if (generated.terrainTextures) { MinecraftMcpServer._writeSingletonJsonMerging(rpBasePath, generated.terrainTextures, filesWritten); } if (generated.itemTextures) { MinecraftMcpServer._writeSingletonJsonMerging(rpBasePath, generated.itemTextures, filesWritten); } if (generated.blocksCatalog) { MinecraftMcpServer._writeSingletonJsonMerging(rpBasePath, generated.blocksCatalog, filesWritten); } if (generated.soundDefinitions) { MinecraftMcpServer._writeSingletonJsonMerging(rpBasePath, generated.soundDefinitions, filesWritten); } if (generated.musicDefinitions) { MinecraftMcpServer._writeSingletonJsonMerging(rpBasePath, generated.musicDefinitions, filesWritten); } // Also merge any items in the sounds array that target singleton files for (const file of generated.sounds) { MinecraftMcpServer._writeSingletonJsonMerging(rpBasePath, file, filesWritten); } // Build summary const summary = generated.summary; let summaryText = `Generated ${filesWritten.length} files for namespace "${summary.namespace}":\n`; summaryText += `- ${summary.entityCount} entity types\n`; summaryText += `- ${summary.blockCount} block types\n`; summaryText += `- ${summary.itemCount} item types\n`; summaryText += `- ${summary.lootTableCount} loot tables\n`; summaryText += `- ${summary.recipeCount} recipes\n`; summaryText += `- ${summary.spawnRuleCount} spawn rules\n`; summaryText += `- ${summary.featureCount} features\n`; summaryText += `\nProject root: ${projectRoot}\n(${resolved.reason})\n`; if (summary.warnings.length > 0) { summaryText += `\nWarnings:\n${summary.warnings.map((w) => `- ${w}`).join("\n")}`; } if (summary.errors.length > 0) { summaryText += `\nErrors:\n${summary.errors.map((e) => `- ${e}`).join("\n")}`; } return { content: [{ type: "text", text: summaryText }], structuredContent: { filesWritten, projectRoot, projectRootReason: resolved.reason, summary: generated.summary, }, }; } catch (error) { return { content: [{ type: "text", text: `Error generating content: ${error}` }], }; } } /** * Finds the first existing pack folder inside a container directory (e.g., behavior_packs/). * Returns the folder path if a pack (folder with manifest.json) is found, otherwise undefined. */ static _findExistingPackFolder(containerPath) { if (!fs.existsSync(containerPath)) { return undefined; } try { for (const entry of fs.readdirSync(containerPath, { withFileTypes: true })) { if (entry.isDirectory()) { const manifestPath = path.join(containerPath, entry.name, "manifest.json"); if (fs.existsSync(manifestPath)) { return path.join(containerPath, entry.name); } } } } catch { // If we can't read the directory, fall through to create a new folder } return undefined; } /** * Returns true when the supplied folder already looks like a Bedrock resource * pack — i.e. it contains a top-level manifest.json whose modules include a * `resources` module. Used by designModel to avoid creating a nested * `resource_packs/` subdirectory inside an RP that the caller passed * directly, which previously caused a recurring "files written to the wrong * path" symptom. */ static _isResourcePackFolder(folderPath) { try { const manifestPath = path.join(folderPath, "manifest.json"); if (!fs.existsSync(manifestPath)) { return false; } const raw = fs.readFileSync(manifestPath, "utf8"); const parsed = JSON.parse(raw); const modules = Array.isArray(parsed?.modules) ? parsed.modules : []; return modules.some((m) => m && typeof m.type === "string" && m.type === "resources"); } catch { return false; } } /** * Sanitize a display name or namespace into a safe folder name. * Lower-cases, replaces non-alphanumeric runs with '_', trims. * Caps length at 64 characters to keep resulting paths well under the * Windows MAX_PATH limit once nested inside the project/pack hierarchy. */ static _toSafeFolderName(name) { return (name .toLowerCase() .replace(/[^a-z0-9]+/g, "_") .replace(/^_+|_+$/g, "") .slice(0, 64) || "addon"); } /** * Returns the list of visible child entries in a directory, or an empty array if * the directory doesn't exist / can't be read. Hidden/system files (.git, .DS_Store, * Thumbs.db, desktop.ini) are ignored when deciding whether a folder is "empty". */ static _visibleChildren(folder) { if (!fs.existsSync(folder)) { return []; } try { return fs.readdirSync(folder).filter((name) => { if (name.startsWith(".")) return false; const lower = name.toLowerCase(); return lower !== "thumbs.db" && lower !== "desktop.ini"; }); } catch { return []; } } /** * Resolves where content should actually be generated, given a user-provided outputPath. * * Heuristic (in priority order): * 1. If outputPath (or a parent within 2 levels) has a Minecraft project reference point * — package.json, behavior_packs/, resource_packs/, or a manifest.json — anchor to * that project root. This is the most common "add to existing project" case. * 2. If outputPath doesn't exist, or is empty (ignoring hidden/system files), use it * directly as the project root. * 3. Otherwise (outputPath is non-empty with unrelated content), create a subfolder * named after the namespace/displayName and use that as the root. * * Returns both the resolved root and a human-readable reason string (useful for the * tool response so agents learn where files landed and why). */ static _resolveProjectRoot(outputPath, definition) { const normalized = path.resolve(outputPath); // Walk up at most 2 levels looking for a project reference point. let current = normalized; for (let depth = 0; depth <= 2; depth++) { if (!fs.existsSync(current)) { // Parent doesn't exist — stop walking up. break; } const entries = new Set(MinecraftMcpServer._visibleChildren(current)); if (entries.has("behavior_packs") || entries.has("resource_packs")) { r