@minecraft/creator-tools
Version:
Minecraft Creator Tools command line and libraries.
1,059 lines • 230 kB
JavaScript
"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