Files
ProjectCapture/devtools/blockbench-mcp/patches/0001-projectcapture-hardening.patch
T

1763 lines
64 KiB
Diff

diff --git a/index.ts b/index.ts
index ef63982..c795f33 100644
--- a/index.ts
+++ b/index.ts
@@ -8,15 +8,15 @@
import { VERSION } from "@/lib/constants";
import { createServer } from "@/server/server";
import { tools, prompts } from "@/server/tools";
-import { resources } from "@/server";
+import { resources } from "@/lib/factories";
import { uiSetup, uiTeardown } from "@/ui";
import { settingsSetup, settingsTeardown } from "@/ui/settings";
import { setupI18n } from "@/ui/i18n";
import { sessionManager } from "@/lib/sessions";
-import { initPromptLoader } from "@/lib/promptLoader";
import type { NetServer, SessionTransports } from "@/server/net";
import createNetServer from "@/server/net";
import { getIcon } from "@/macros/getIcon" with { type: "macro" };
+import { readProjectCaptureSecurityConfig } from "@/lib/projectcaptureSecurity";
let httpServer: NetServer | null = null;
let sessionTransports: SessionTransports | null = null;
@@ -34,6 +34,15 @@ BBPlugin.register("mcp", {
icon: getIcon(),
variant: "desktop",
async onload() {
+ let securityConfig;
+ try {
+ securityConfig = readProjectCaptureSecurityConfig();
+ } catch (error) {
+ console.error("[MCP] ProjectCapture security configuration is incomplete:", error);
+ Blockbench.showQuickMessage("ProjectCapture MCP requires its token and asset-root environment variables", 5000);
+ return;
+ }
+
// Get network module with Blockbench permission handling
// @ts-ignore - requireNativeModule is a Blockbench global
const net = requireNativeModule("net", {
@@ -53,16 +62,6 @@ BBPlugin.register("mcp", {
settingsSetup();
- // Load prompt manifest from CDN/cache before server starts.
- // Must never abort onload — missing prompts should degrade gracefully,
- // e.g. when a new version is tagged before the CDN asset is published.
- try {
- const cdnEnabled = Settings.get("mcp_prompt_cdn_enabled") !== false;
- await initPromptLoader(cdnEnabled);
- } catch (err) {
- console.error("[MCP] Prompt loader initialization failed — continuing without prompts:", err);
- }
-
// Create TCP server to handle HTTP requests
const toFiniteNumber = (raw: unknown, fallback: number): number => {
const n = Number(raw);
@@ -79,6 +78,8 @@ BBPlugin.register("mcp", {
[httpServer, sessionTransports] = createNetServer(net, {
port: Number(Settings.get("mcp_port") || 3000),
endpoint: String(Settings.get("mcp_endpoint") || "/bb-mcp"),
+ host: securityConfig.host,
+ bearerToken: securityConfig.bearerToken,
keepAlive: {
sseHeartbeatIntervalMs: Math.max(0, sseHeartbeatSec) * 1000,
},
diff --git a/lib/factories.ts b/lib/factories.ts
index 14fe412..2bc9113 100644
--- a/lib/factories.ts
+++ b/lib/factories.ts
@@ -2,6 +2,7 @@ import { z } from "zod";
import type { IMCPTool, IMCPPrompt, IMCPResource, StatusType } from "@/types";
import { getServer } from "@/server/server";
import { ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
+import { isProjectCaptureToolAllowed } from "@/lib/projectcaptureAllowlist";
/**
* Declarative tool spec for documentation and registration.
@@ -158,8 +159,12 @@ export function createTool<T extends z.ZodType>(
// Store tool definition
toolDefinitions[name] = toolDef;
- // Register with server if enabled
- if (enabled) {
+ // ProjectCapture is fail-closed: upstream tools are visible to the local UI
+ // as disabled, but only the reviewed allowlist is reachable over MCP.
+ const policyEnabled = enabled && isProjectCaptureToolAllowed(name);
+
+ // Register with server if enabled by both the caller and policy.
+ if (policyEnabled) {
type ToolArgs = z.infer<T>;
const server = getServer();
@@ -214,7 +219,7 @@ export function createTool<T extends z.ZodType>(
tools[name] = {
name,
description: toolDef.title,
- enabled,
+ enabled: policyEnabled,
status,
};
diff --git a/lib/projectcaptureAllowlist.ts b/lib/projectcaptureAllowlist.ts
new file mode 100644
index 0000000..68d7e2c
--- /dev/null
+++ b/lib/projectcaptureAllowlist.ts
@@ -0,0 +1,105 @@
+/**
+ * ProjectCapture deliberately exposes a narrow Blockbench tool surface.
+ *
+ * The upstream plugin contains useful general-purpose tools, but it also ships
+ * remote JavaScript evaluation, arbitrary UI actions/clicks and URL imports.
+ * Keeping the policy here makes registration fail closed for both the initial
+ * MCP server and all per-session servers.
+ */
+export const PROJECTCAPTURE_TOOL_ALLOWLIST = new Set<string>([
+ // Project orientation and deterministic Hytale project setup.
+ "get_project_info",
+ "create_hytale_project",
+ "hytale_capability_probe",
+ "hytale_validate_project",
+
+ // Hytale-compatible cube/group modelling.
+ "place_cube",
+ "modify_cube",
+ "remove_element",
+ "add_group",
+ "list_outline",
+ "duplicate_element",
+ "rename_element",
+ "find_elements_by_criteria",
+ "select_all_of_type",
+ "filter_by_material",
+ "get_selection",
+
+ // Texture creation and editing. Filesystem access is provided only by the
+ // sandboxed ProjectCapture tools below.
+ "create_texture",
+ "apply_texture",
+ "list_textures",
+ "get_texture",
+ "activate_texture",
+ "paint_fill_tool",
+ "draw_shape_tool",
+ "gradient_tool",
+ "color_picker_tool",
+ "copy_brush_tool",
+ "eraser_tool",
+ "paint_settings",
+ "paint_with_brush",
+ "create_brush_preset",
+ "load_brush_preset",
+ "texture_selection",
+ "texture_layer_management",
+ "hytale_import_texture_png",
+ "hytale_save_texture_png",
+
+ // Rigging and animation. None of these execute caller-supplied code or
+ // arbitrary Blockbench actions.
+ "create_animation",
+ "manage_keyframes",
+ "animation_graph_editor",
+ "bone_rigging",
+ "animation_timeline",
+ "batch_keyframe_operations",
+ "animation_copy_paste",
+ "hytale_create_visibility_keyframe",
+ "hytale_set_animation_loop",
+ "hytale_export_blockyanim",
+
+ // Hytale metadata, quads and attachments.
+ "hytale_get_format_info",
+ "hytale_validate_model",
+ "hytale_set_cube_properties",
+ "hytale_get_cube_properties",
+ "hytale_create_quad",
+ "hytale_list_attachments",
+ "hytale_set_attachment_piece",
+ "hytale_list_attachment_pieces",
+ "hytale_set_cube_stretch",
+ "hytale_get_cube_stretch",
+ "hytale_create_attachment",
+
+ // Sandboxed project/model persistence.
+ "list_export_formats",
+ "hytale_save_bbmodel",
+ "hytale_export_blockymodel",
+
+ // Reproducible preview and local edit history.
+ "capture_screenshot",
+ "set_camera_angle",
+ "undo",
+ "redo",
+ "get_undo_stack",
+ "save_checkpoint",
+]);
+
+export const PROJECTCAPTURE_FORBIDDEN_TOOLS = new Set<string>([
+ "risky_eval",
+ "trigger_action",
+ "emulate_clicks",
+ "import_geojson",
+ "import_texture_set",
+ "save_material_config",
+ "export_model",
+ "capture_app_screenshot",
+]);
+
+export function isProjectCaptureToolAllowed(name: string): boolean {
+ return PROJECTCAPTURE_TOOL_ALLOWLIST.has(name) &&
+ !PROJECTCAPTURE_FORBIDDEN_TOOLS.has(name);
+}
diff --git a/lib/projectcaptureHytale.ts b/lib/projectcaptureHytale.ts
new file mode 100644
index 0000000..ca6d17d
--- /dev/null
+++ b/lib/projectcaptureHytale.ts
@@ -0,0 +1,175 @@
+export const HYTALE_FORMAT_IDS = ["hytale_character", "hytale_prop"] as const;
+export const HYTALE_SHADING_MODES = [
+ "flat",
+ "standard",
+ "fullbright",
+ "reflective",
+] as const;
+export const HYTALE_NORMALS = ["+X", "-X", "+Y", "-Y", "+Z", "-Z"] as const;
+
+export type HytaleNormal = (typeof HYTALE_NORMALS)[number];
+
+export const HYTALE_FACE_BY_NORMAL: Record<HytaleNormal, string> = {
+ "+X": "east",
+ "-X": "west",
+ "+Y": "up",
+ "-Y": "down",
+ "+Z": "south",
+ "-Z": "north",
+};
+
+export function hytaleQuadBounds(
+ position: [number, number, number],
+ normal: HytaleNormal,
+ size: [number, number],
+): { from: [number, number, number]; to: [number, number, number]; face: string } {
+ const [x, y, z] = position;
+ const [width, height] = size;
+ const from: [number, number, number] = [x, y, z];
+ let to: [number, number, number];
+ if (normal.endsWith("X")) {
+ to = [x, y + height, z + width];
+ } else if (normal.endsWith("Y")) {
+ to = [x + width, y, z + height];
+ } else {
+ to = [x + width, y + height, z];
+ }
+ return { from, to, face: HYTALE_FACE_BY_NORMAL[normal] };
+}
+
+export function keyframeDataPoint(
+ channel: "position" | "rotation" | "scale" | "visibility",
+ value: number | number[] | boolean,
+): Record<string, number | boolean> {
+ if (channel === "visibility") {
+ if (typeof value !== "boolean") {
+ throw new Error("Visibility keyframes require a boolean value.");
+ }
+ return { visibility: value };
+ }
+ if (typeof value === "boolean") {
+ throw new Error(`${channel} keyframes require numeric values.`);
+ }
+ const vector = Array.isArray(value) ? value : [value, value, value];
+ if (vector.length !== 3 || vector.some((entry) => !Number.isFinite(entry))) {
+ throw new Error(`${channel} keyframes require three finite numeric values.`);
+ }
+ return { x: vector[0], y: vector[1], z: vector[2] };
+}
+
+export async function awaitCompiledPayload<T>(value: T | Promise<T>): Promise<T> {
+ return await value;
+}
+
+export interface HytaleValidationSnapshot {
+ formatId: string | null;
+ nodeCount: number;
+ groups: Array<{ name: string; visibility: unknown }>;
+ cubes: Array<{
+ name: string;
+ visibility: unknown;
+ shadingMode: string | undefined;
+ zeroAxes: number;
+ texturedFaces: string[];
+ }>;
+ meshes: number;
+ textures: Array<{ name: string; width: number; height: number }>;
+}
+
+export interface HytaleValidationResult {
+ valid: boolean;
+ errors: string[];
+ warnings: string[];
+}
+
+export function validateHytaleSnapshot(
+ snapshot: HytaleValidationSnapshot,
+): HytaleValidationResult {
+ const errors: string[] = [];
+ const warnings: string[] = [];
+ if (!HYTALE_FORMAT_IDS.includes(snapshot.formatId as typeof HYTALE_FORMAT_IDS[number])) {
+ errors.push("Project is not a Hytale character or prop project.");
+ }
+ if (snapshot.nodeCount > 255) {
+ errors.push(`Node count ${snapshot.nodeCount} exceeds Hytale's limit of 255.`);
+ }
+ if (snapshot.meshes > 0) {
+ errors.push("Hytale projects may contain cubes/quads and groups only; meshes are unsupported.");
+ }
+
+ const groupNames = new Set<string>();
+ for (const group of snapshot.groups) {
+ if (groupNames.has(group.name)) {
+ errors.push(`Duplicate group/bone name "${group.name}" is animation-ambiguous.`);
+ }
+ groupNames.add(group.name);
+ if (typeof group.visibility !== "boolean") {
+ errors.push(`Group "${group.name}" has a non-boolean visibility value.`);
+ }
+ }
+
+ for (const cube of snapshot.cubes) {
+ if (typeof cube.visibility !== "boolean") {
+ errors.push(`Cube "${cube.name}" has a non-boolean visibility value.`);
+ }
+ if (!cube.shadingMode) {
+ warnings.push(`Cube "${cube.name}" has no shading mode; flat will be used.`);
+ } else if (!HYTALE_SHADING_MODES.includes(cube.shadingMode as typeof HYTALE_SHADING_MODES[number])) {
+ errors.push(`Cube "${cube.name}" uses unsupported shading mode "${cube.shadingMode}".`);
+ }
+ if (cube.zeroAxes > 1) {
+ errors.push(`Cube "${cube.name}" collapses on more than one axis and is not a valid quad.`);
+ }
+ if (cube.zeroAxes === 1 && cube.texturedFaces.length !== 1) {
+ errors.push(`Quad "${cube.name}" must have exactly one textured face.`);
+ }
+ }
+
+ const blockSize = snapshot.formatId === "hytale_prop" ? 32 : 64;
+ if (snapshot.textures.length > 1) {
+ warnings.push("Hytale base models should normally use one texture; attachments use collections.");
+ }
+ for (const texture of snapshot.textures) {
+ if (texture.width !== blockSize || texture.height < blockSize || texture.height % blockSize !== 0) {
+ errors.push(
+ `Texture "${texture.name}" is ${texture.width}x${texture.height}; expected ${blockSize}px width and a positive ${blockSize}px height multiple.`,
+ );
+ }
+ }
+
+ return { valid: errors.length === 0, errors, warnings };
+}
+
+export interface CapabilityRegistry {
+ blockbenchVersion: string | null;
+ hytalePluginVersion: string | null;
+ formats: readonly string[];
+ codecs: readonly string[];
+ tools: readonly string[];
+}
+
+export function buildCapabilityReport(registry: CapabilityRegistry) {
+ const requirements = {
+ hytalePlugin: registry.hytalePluginVersion !== null,
+ characterFormat: registry.formats.includes("hytale_character"),
+ propFormat: registry.formats.includes("hytale_prop"),
+ blockymodelCodec: registry.codecs.includes("blockymodel"),
+ safeBbmodelSave: registry.tools.includes("hytale_save_bbmodel"),
+ safePngSave: registry.tools.includes("hytale_save_texture_png"),
+ blockyanimExport: registry.tools.includes("hytale_export_blockyanim"),
+ };
+ return {
+ blockbenchVersion: registry.blockbenchVersion,
+ hytalePluginVersion: registry.hytalePluginVersion,
+ security: {
+ loopbackOnly: true,
+ bearerAuthentication: true,
+ assetRootSandbox: true,
+ arbitraryEvaluation: false,
+ arbitraryUiActions: false,
+ remoteUrlImports: false,
+ },
+ requirements,
+ ready: Object.values(requirements).every(Boolean),
+ };
+}
diff --git a/lib/projectcaptureSecurity.ts b/lib/projectcaptureSecurity.ts
new file mode 100644
index 0000000..eda0255
--- /dev/null
+++ b/lib/projectcaptureSecurity.ts
@@ -0,0 +1,223 @@
+import path from "node:path";
+
+export const PROJECTCAPTURE_LOOPBACK_HOST = "127.0.0.1" as const;
+export const PROJECTCAPTURE_TOKEN_ENV = "PROJECTCAPTURE_BLOCKBENCH_TOKEN";
+export const PROJECTCAPTURE_ASSET_ROOT_ENV = "PROJECTCAPTURE_ASSET_ROOT";
+export const PROJECTCAPTURE_MIN_TOKEN_LENGTH = 32;
+
+export interface ProjectCaptureSecurityConfig {
+ host: typeof PROJECTCAPTURE_LOOPBACK_HOST;
+ bearerToken: string;
+ assetRoot: string;
+}
+
+export interface AssetSandboxFs {
+ existsSync(path: string): boolean;
+ lstatSync?(path: string): {
+ isDirectory(): boolean;
+ isFile(): boolean;
+ isSymbolicLink(): boolean;
+ };
+ statSync?(path: string): {
+ isDirectory(): boolean;
+ isFile(): boolean;
+ };
+ realpathSync(path: string): string;
+ mkdirSync(path: string, options?: { mode?: number }): unknown;
+}
+
+type AssetStat = {
+ isDirectory(): boolean;
+ isFile(): boolean;
+ isSymbolicLink?(): boolean;
+};
+
+function inspectPath(pathname: string, fs: AssetSandboxFs): AssetStat {
+ if (typeof fs.lstatSync === "function") return fs.lstatSync(pathname);
+ if (typeof fs.statSync === "function") return fs.statSync(pathname);
+ throw new Error("Blockbench filesystem bridge does not expose a safe stat operation.");
+}
+
+function isSymbolicLink(pathname: string, stat: AssetStat, fs: AssetSandboxFs): boolean {
+ if (typeof stat.isSymbolicLink === "function") return stat.isSymbolicLink();
+ return fs.realpathSync(pathname) !== path.resolve(pathname);
+}
+
+function requireSecretToken(value: string | undefined): string {
+ const token = value?.trim();
+ if (!token || token.length < PROJECTCAPTURE_MIN_TOKEN_LENGTH) {
+ throw new Error(
+ `${PROJECTCAPTURE_TOKEN_ENV} must contain at least ${PROJECTCAPTURE_MIN_TOKEN_LENGTH} characters.`,
+ );
+ }
+ if (/\s/.test(token)) {
+ throw new Error(`${PROJECTCAPTURE_TOKEN_ENV} must not contain whitespace.`);
+ }
+ return token;
+}
+
+function requireAbsoluteAssetRoot(value: string | undefined): string {
+ if (!value?.trim()) {
+ throw new Error(`${PROJECTCAPTURE_ASSET_ROOT_ENV} is required.`);
+ }
+ if (value.includes("\0") || !path.isAbsolute(value)) {
+ throw new Error(`${PROJECTCAPTURE_ASSET_ROOT_ENV} must be an absolute path.`);
+ }
+ return path.resolve(value);
+}
+
+export function readProjectCaptureSecurityConfig(
+ env: Record<string, string | undefined> = process.env,
+): ProjectCaptureSecurityConfig {
+ return {
+ host: PROJECTCAPTURE_LOOPBACK_HOST,
+ bearerToken: requireSecretToken(env[PROJECTCAPTURE_TOKEN_ENV]),
+ assetRoot: requireAbsoluteAssetRoot(env[PROJECTCAPTURE_ASSET_ROOT_ENV]),
+ };
+}
+
+function constantTimeEqual(left: string, right: string): boolean {
+ const encoder = new TextEncoder();
+ const a = encoder.encode(left);
+ const b = encoder.encode(right);
+ const length = Math.max(a.length, b.length, 1);
+ let difference = a.length ^ b.length;
+ for (let i = 0; i < length; i += 1) {
+ difference |= (a[i % Math.max(a.length, 1)] ?? 0) ^
+ (b[i % Math.max(b.length, 1)] ?? 0);
+ }
+ return difference === 0;
+}
+
+export function isAuthorizedBearer(
+ authorizationHeader: string | undefined,
+ expectedToken: string,
+): boolean {
+ if (!authorizationHeader) return false;
+ const match = /^Bearer ([^\s]+)$/.exec(authorizationHeader);
+ return Boolean(match && constantTimeEqual(match[1], expectedToken));
+}
+
+function assertInsideRoot(root: string, candidate: string): void {
+ const relative = path.relative(root, candidate);
+ if (
+ relative === "" ||
+ relative === ".." ||
+ relative.startsWith(`..${path.sep}`) ||
+ path.isAbsolute(relative)
+ ) {
+ throw new Error("Asset path escapes the configured ProjectCapture root.");
+ }
+}
+
+function validateRelativePath(requestedPath: string): string {
+ if (!requestedPath || requestedPath.includes("\0") || path.isAbsolute(requestedPath)) {
+ throw new Error("Asset path must be a non-empty relative path.");
+ }
+ const normalized = path.normalize(requestedPath);
+ if (
+ normalized === "." ||
+ normalized === ".." ||
+ normalized.startsWith(`..${path.sep}`)
+ ) {
+ throw new Error("Asset path escapes the configured ProjectCapture root.");
+ }
+ return normalized;
+}
+
+function assertAllowedExtension(
+ candidate: string,
+ allowedExtensions: readonly string[],
+): void {
+ const extension = path.extname(candidate).toLowerCase();
+ const normalizedAllowed = allowedExtensions.map((item) => item.toLowerCase());
+ if (!normalizedAllowed.includes(extension)) {
+ throw new Error(
+ `Asset extension "${extension || "(none)"}" is not allowed; expected ${normalizedAllowed.join(", ")}.`,
+ );
+ }
+}
+
+function resolveRoot(assetRoot: string, fs: AssetSandboxFs): string {
+ const root = path.resolve(assetRoot);
+ if (!fs.existsSync(root)) {
+ throw new Error("Configured ProjectCapture asset root does not exist.");
+ }
+ const rootStat = inspectPath(root, fs);
+ if (isSymbolicLink(root, rootStat, fs) || !rootStat.isDirectory()) {
+ throw new Error("Configured ProjectCapture asset root must be a real directory, not a symlink.");
+ }
+ return fs.realpathSync(root);
+}
+
+function ensureSafeParentDirectories(
+ root: string,
+ relativePath: string,
+ fs: AssetSandboxFs,
+): void {
+ const parent = path.dirname(relativePath);
+ if (parent === ".") return;
+
+ let cursor = root;
+ for (const segment of parent.split(path.sep)) {
+ if (!segment || segment === ".") continue;
+ cursor = path.join(cursor, segment);
+ assertInsideRoot(root, cursor);
+ if (fs.existsSync(cursor)) {
+ const stat = inspectPath(cursor, fs);
+ if (isSymbolicLink(cursor, stat, fs) || !stat.isDirectory()) {
+ throw new Error("Asset path contains a symlink or non-directory parent.");
+ }
+ } else {
+ fs.mkdirSync(cursor, { mode: 0o750 });
+ }
+ }
+
+ const realParent = fs.realpathSync(path.join(root, parent));
+ assertInsideRoot(root, realParent);
+}
+
+export function prepareAssetWritePath(
+ assetRoot: string,
+ requestedPath: string,
+ allowedExtensions: readonly string[],
+ fs: AssetSandboxFs,
+): string {
+ const relativePath = validateRelativePath(requestedPath);
+ assertAllowedExtension(relativePath, allowedExtensions);
+ const root = resolveRoot(assetRoot, fs);
+ ensureSafeParentDirectories(root, relativePath, fs);
+
+ const candidate = path.resolve(root, relativePath);
+ assertInsideRoot(root, candidate);
+ if (fs.existsSync(candidate)) {
+ const stat = inspectPath(candidate, fs);
+ if (isSymbolicLink(candidate, stat, fs) || !stat.isFile()) {
+ throw new Error("Asset target must be a regular file and must not be a symlink.");
+ }
+ }
+ return candidate;
+}
+
+export function resolveAssetReadPath(
+ assetRoot: string,
+ requestedPath: string,
+ allowedExtensions: readonly string[],
+ fs: AssetSandboxFs,
+): string {
+ const relativePath = validateRelativePath(requestedPath);
+ assertAllowedExtension(relativePath, allowedExtensions);
+ const root = resolveRoot(assetRoot, fs);
+ const lexicalCandidate = path.resolve(root, relativePath);
+ assertInsideRoot(root, lexicalCandidate);
+ if (!fs.existsSync(lexicalCandidate)) {
+ throw new Error("Requested asset does not exist.");
+ }
+ const stat = inspectPath(lexicalCandidate, fs);
+ if (isSymbolicLink(lexicalCandidate, stat, fs) || !stat.isFile()) {
+ throw new Error("Requested asset must be a regular file and must not be a symlink.");
+ }
+ const realCandidate = fs.realpathSync(lexicalCandidate);
+ assertInsideRoot(root, realCandidate);
+ return realCandidate;
+}
diff --git a/package.json b/package.json
index 8d33e2a..10bca2c 100644
--- a/package.json
+++ b/package.json
@@ -11,11 +11,11 @@
"type": "module",
"main": "dist/mcp.js",
"scripts": {
- "test": "echo \"Error: no test specified\" && exit 1",
+ "test": "bun test ./tests",
"prompts:build": "bun run ./build/generate-manifest.ts",
"dev": "bun run prompts:build && bun run ./build --sourcemap",
"dev:watch": "bun run prompts:build && bun run dev --watch",
- "build": "bun run prompts:build && bun run ./build --minify",
+ "build": "bun run ./build --minify",
"docs:build": "bun run ./build/docs.ts",
"docs:serve": "bun run docs/index.html",
"docs": "bun run prompts:build && bun run docs:build && bun run docs:serve",
@@ -32,4 +32,4 @@
"typescript": "^5.9.3",
"zod-to-json-schema": "^3.25.1"
}
-}
\ No newline at end of file
+}
diff --git a/server/net.ts b/server/net.ts
index ea411f6..2bfcca2 100644
--- a/server/net.ts
+++ b/server/net.ts
@@ -9,6 +9,10 @@ import {
} from '@/lib/factories'
import { createServer as createMcpServer } from '@/server/server'
import { sessionManager, type SessionConfig } from '@/lib/sessions'
+import {
+ isAuthorizedBearer,
+ PROJECTCAPTURE_LOOPBACK_HOST
+} from '@/lib/projectcaptureSecurity'
export type { NetServer }
@@ -61,6 +65,7 @@ function getStatusText (status: number): string {
202: 'Accepted',
204: 'No Content',
400: 'Bad Request',
+ 401: 'Unauthorized',
404: 'Not Found',
405: 'Method Not Allowed',
409: 'Conflict',
@@ -97,16 +102,22 @@ export default function createNetServer (
{
port,
endpoint,
+ host = PROJECTCAPTURE_LOOPBACK_HOST,
+ bearerToken,
keepAlive = DEFAULT_KEEP_ALIVE,
sessionConfig
}: {
endpoint: string
port: number
host?: string
+ bearerToken: string
keepAlive?: Partial<KeepAliveConfig>
sessionConfig?: Partial<SessionConfig>
}
): [NetServer, SessionTransports] {
+ if (host !== PROJECTCAPTURE_LOOPBACK_HOST) {
+ throw new Error(`ProjectCapture MCP may bind only to ${PROJECTCAPTURE_LOOPBACK_HOST}`)
+ }
const sessionTransports: SessionTransports = new Map()
const keepAliveConfig = { ...DEFAULT_KEEP_ALIVE, ...keepAlive }
@@ -240,6 +251,23 @@ export default function createNetServer (
}
}
+ // Authenticate before reading or parsing a request body. Unauthorized
+ // clients get no health/session information and the socket is closed.
+ if (!isAuthorizedBearer(headers['authorization'], bearerToken)) {
+ buffer = Buffer.alloc(0)
+ sendResponse(
+ socket,
+ 401,
+ {
+ 'content-type': 'application/json',
+ 'www-authenticate': 'Bearer realm="ProjectCapture Blockbench MCP"'
+ },
+ JSON.stringify({ error: 'Unauthorized' }),
+ 'close'
+ )
+ return
+ }
+
// Calculate body boundaries
const bodyStart = headerEnd + 4
const contentLength = parseInt(headers['content-length'] || '0', 10)
@@ -252,7 +280,7 @@ export default function createNetServer (
buffer = buffer.subarray(requestEnd)
// Build Web Standard Request
- const url = `http://localhost:${port}${path}`
+ const url = `http://${host}:${port}${path}`
const webHeaders = new Headers()
for (const [key, value] of Object.entries(headers)) {
webHeaders.set(key, value)
@@ -614,8 +642,8 @@ export default function createNetServer (
}
})
- httpServer.listen(port, () => {
- console.log(`[MCP] Server listening on http://localhost:${port}${endpoint}`)
+ httpServer.listen(port, host, () => {
+ console.log(`[MCP] Hardened server listening on http://${host}:${port}${endpoint}`)
})
httpServer.on('error', (err: Error) => {
diff --git a/server/tools.ts b/server/tools.ts
index 3849159..da6cea9 100644
--- a/server/tools.ts
+++ b/server/tools.ts
@@ -8,17 +8,12 @@ import { registerCameraTools } from "./tools/camera";
import { registerAnimationTools } from "./tools/animation";
import { registerCubesTools } from "./tools/cubes";
import { registerElementTools } from "./tools/element";
-import { registerImportTools } from "./tools/import";
-import { registerMeshTools } from "./tools/mesh";
import { registerPaintTools } from "./tools/paint";
import { registerProjectTools } from "./tools/project";
import { registerTextureTools } from "./tools/texture";
-import { registerUITools } from "./tools/ui";
-import { registerUVTools } from "./tools/uv";
-import { registerMaterialInstanceTools } from "./tools/material-instances";
-import { registerArmatureTools } from "./tools/armature";
import { registerHistoryTools } from "./tools/history";
import { registerExportTools } from "./tools/export";
+import { registerProjectCaptureTools } from "./tools/projectcapture";
// Core resource registrations
import { registerValidatorResources } from "./resources/validator";
@@ -26,25 +21,19 @@ import { registerValidatorResources } from "./resources/validator";
// Optional plugin integrations (conditionally registered)
import { registerHytaleTools } from "./tools/hytale";
import { registerHytaleResources } from "./resources/hytale";
-import { registerHytalePrompts } from "./prompts/hytale";
// All registration functions - MUST be used to prevent tree-shaking
const registrationFunctions = [
registerAnimationTools,
- registerArmatureTools,
registerCameraTools,
registerCubesTools,
registerElementTools,
registerExportTools,
registerHistoryTools,
- registerImportTools,
- registerMaterialInstanceTools,
- registerMeshTools,
registerPaintTools,
registerProjectTools,
+ registerProjectCaptureTools,
registerTextureTools,
- registerUITools,
- registerUVTools,
registerValidatorResources,
];
@@ -53,7 +42,6 @@ const registrationFunctions = [
const optionalRegistrationFunctions = [
registerHytaleTools,
registerHytaleResources,
- registerHytalePrompts,
];
// Register all core tools immediately when this module loads
diff --git a/server/tools/animation.ts b/server/tools/animation.ts
index 0b3832d..6f08212 100644
--- a/server/tools/animation.ts
+++ b/server/tools/animation.ts
@@ -16,6 +16,7 @@ import {
loopModeEnum,
keyframeDataSchema,
} from "@/lib/zodObjects";
+import { keyframeDataPoint } from "@/lib/projectcaptureHytale";
export const createAnimationParameters = z.object({
name: z.string().describe("Name of the animation"),
@@ -330,6 +331,40 @@ createTool(
{
...animationToolDocs[0],
async execute({ name, loop, animation_length, bones, particle_effects }) {
+ if (Format && ["hytale_character", "hytale_prop"].includes(Format.id)) {
+ if (particle_effects && Object.keys(particle_effects).length > 0) {
+ throw new Error("Hytale blockyanim export does not support Bedrock particle-effect entries.");
+ }
+ const animation = new Animation({
+ name,
+ length: animation_length ?? 2,
+ loop: loop ? "loop" : "once",
+ snapping: 60,
+ }).add(false);
+
+ for (const [boneName, keyframes] of Object.entries(bones)) {
+ const group = findGroupOrThrow(boneName);
+ const animator = new BoneAnimator(group.uuid, animation, boneName);
+ animation.animators[group.uuid] = animator;
+ animator.group = group;
+ for (const keyframe of keyframes) {
+ for (const channel of ["position", "rotation", "scale"] as const) {
+ const value = keyframe[channel];
+ if (value === undefined) continue;
+ animator.addKeyframe({
+ channel,
+ time: keyframe.time,
+ interpolation: "linear",
+ data_points: [keyframeDataPoint(channel, value)],
+ });
+ }
+ }
+ }
+ animation.select();
+ Animator.preview();
+ return `Created Hytale animation "${name}" at 60 FPS for ${Object.keys(bones).length} bones`;
+ }
+
const animationData = {
loop,
...(animation_length && { animation_length }),
@@ -417,7 +452,7 @@ createTool(
{
time: kf.time,
channel,
- values: kf.values,
+ data_points: [keyframeDataPoint(channel, kf.values ?? [0, 0, 0])],
interpolation: kf.interpolation,
},
kf.time,
@@ -460,7 +495,10 @@ createTool(
);
if (keyframe) {
if (kf.values) {
- keyframe.set("values", kf.values);
+ Object.assign(
+ keyframe.data_points[0],
+ keyframeDataPoint(channel, kf.values),
+ );
}
if (kf.interpolation) {
keyframe.interpolation = kf.interpolation;
diff --git a/server/tools/cubes.ts b/server/tools/cubes.ts
index c4d1ca4..95083ca 100644
--- a/server/tools/cubes.ts
+++ b/server/tools/cubes.ts
@@ -162,12 +162,21 @@ createTool(cubeToolDocs[0].name, {
rotation: element.rotation as [number, number, number],
}).init();
+ if (Format && ["hytale_character", "hytale_prop"].includes(Format.id)) {
+ // Hytale's own plugin defaults shapes to flat shading. Persist the
+ // explicit property so exports remain deterministic across versions.
+ (cube as Cube & { shading_mode?: string }).shading_mode = "flat";
+ cube.visibility = true;
+ cube.shade = false;
+ }
+
cube.addTo(outlinerGroup);
if (!autouv && Array.isArray(faces)) {
faces.forEach(({ face, uv }) => {
cube.faces[face].extend({
uv: uv as [number, number, number, number],
+ texture: projectTexture.uuid,
});
});
} else {
diff --git a/server/tools/element.ts b/server/tools/element.ts
index 7692106..7022546 100644
--- a/server/tools/element.ts
+++ b/server/tools/element.ts
@@ -91,9 +91,9 @@ export const filterByMaterialParameters = z.object({
export const getSelectionParameters = z.object({});
export const addGroupParameters = z.object({
- name: z.string(),
- origin: vector3Schema,
- rotation: vector3Schema,
+ name: z.string().min(1).max(128),
+ origin: vector3Schema.default([0, 0, 0]),
+ rotation: vector3Schema.default([0, 0, 0]),
parent: z.string().optional().default("root"),
visibility: z.boolean().optional().default(true),
autouv: autoUvEnum
@@ -103,7 +103,7 @@ export const addGroupParameters = z.object({
"Auto UV setting. 0 = disabled, 1 = enabled, 2 = relative auto UV."
),
selected: z.boolean().optional().default(false),
- shade: z.boolean().optional().default(false),
+ shade: z.boolean().optional().default(true),
});
export const listOutlineParameters = z.object({
@@ -355,6 +355,14 @@ export function registerElementTools() {
collections: [],
});
+ const parentGroup = parent === "root"
+ ? "root"
+ : // `@ts-expect-error` getAllGroups is a Blockbench global
+ getAllGroups().find((g: Group) => g.name === parent || g.uuid === parent);
+ if (!parentGroup) {
+ throw new Error(`Parent group "${parent}" was not found.`);
+ }
+
const group = new Group({
name,
origin,
@@ -365,10 +373,6 @@ export function registerElementTools() {
shade: Boolean(shade),
}).init();
- const parentGroup = parent === "root"
- ? "root"
- : // `@ts-expect-error` getAllGroups is a Blockbench global
- getAllGroups().find((g: Group) => g.name === parent || g.uuid === parent);
group.addTo(parentGroup);
Undo.finishEdit("Agent added group");
diff --git a/server/tools/export.ts b/server/tools/export.ts
index 6e7366b..77886cc 100644
--- a/server/tools/export.ts
+++ b/server/tools/export.ts
@@ -201,7 +201,9 @@ export function registerExportTools() {
? codec.getExportOptions()
: undefined);
- const rawResult = codec.compile(effectiveOptions);
+ // Some codecs (including evolving Hytale integrations) compile
+ // asynchronously. Always await so Promise objects are never serialized.
+ const rawResult = await codec.compile(effectiveOptions);
const isArrayBuffer = rawResult instanceof ArrayBuffer;
const isBinaryView =
diff --git a/server/tools/hytale.ts b/server/tools/hytale.ts
index da83293..8a9fa0f 100644
--- a/server/tools/hytale.ts
+++ b/server/tools/hytale.ts
@@ -31,6 +31,7 @@ import {
stretchSchema,
size2dSchema,
} from "@/lib/zodObjects";
+import { hytaleQuadBounds, keyframeDataPoint } from "@/lib/projectcaptureHytale";
// ============================================================================
// Hytale-Specific Enums
@@ -460,32 +461,10 @@ export function registerHytaleTools() {
parentGroup = findGroupOrThrow(group);
}
- // Calculate from/to based on normal direction and size
- const [width, height] = size;
- const [x, y, z] = position;
- let from: [number, number, number];
- let to: [number, number, number];
-
- // Quads are essentially very thin cubes (0 depth in one dimension)
- switch (normal) {
- case "+X":
- case "-X":
- from = [x, y, z];
- to = [x, y + height, z + width];
- break;
- case "+Y":
- case "-Y":
- from = [x, y, z];
- to = [x + width, y, z + height];
- break;
- case "+Z":
- case "-Z":
- from = [x, y, z];
- to = [x + width, y + height, z];
- break;
- default:
- from = [x, y, z];
- to = [x + width, y + height, z];
+ const { from, to, face } = hytaleQuadBounds(position, normal, size);
+ const texture = Texture.getDefault();
+ if (!texture) {
+ throw new Error("Create or import a Hytale texture before creating a quad.");
}
// @ts-ignore - Undo is globally available
@@ -497,12 +476,21 @@ export function registerHytaleTools() {
from,
to,
autouv: 1,
+ box_uv: false,
+ visibility: true,
}).init();
+ // The official Hytale codec infers the quad normal from the one and
+ // only textured face. Explicitly clear the other five faces.
+ for (const [faceName, cubeFace] of Object.entries(cube.faces)) {
+ cubeFace.texture = faceName === face ? texture.uuid : null;
+ }
+ cube.mapAutoUV();
+
// Set Hytale-specific properties
const hytaleCube = cube as HytaleCube;
hytaleCube.double_sided = double_sided;
- hytaleCube.shading_mode = "standard";
+ hytaleCube.shading_mode = "flat";
// Add to parent group if specified
if (parentGroup) {
@@ -519,6 +507,7 @@ export function registerHytaleTools() {
uuid: cube.uuid,
name: cube.name,
normal,
+ face,
from,
to,
double_sided,
@@ -658,7 +647,7 @@ export function registerHytaleTools() {
const keyframe = animator.addKeyframe({
channel: "visibility",
time,
- data_points: [{ visible }],
+ data_points: [keyframeDataPoint("visibility", visible)],
});
// @ts-ignore - Undo is globally available
diff --git a/server/tools/projectcapture.ts b/server/tools/projectcapture.ts
new file mode 100644
index 0000000..41977e6
--- /dev/null
+++ b/server/tools/projectcapture.ts
@@ -0,0 +1,401 @@
+/// <reference types="three" />
+/// <reference types="blockbench-types" />
+
+import { z } from "zod";
+import { createTool, tools, type ToolSpec } from "@/lib/factories";
+import { STATUS_EXPERIMENTAL, STATUS_STABLE } from "@/lib/constants";
+import {
+ awaitCompiledPayload,
+ buildCapabilityReport,
+ HYTALE_FORMAT_IDS,
+ validateHytaleSnapshot,
+} from "@/lib/projectcaptureHytale";
+import {
+ prepareAssetWritePath,
+ readProjectCaptureSecurityConfig,
+ resolveAssetReadPath,
+ type AssetSandboxFs,
+} from "@/lib/projectcaptureSecurity";
+import { findGroupOrThrow, findTextureOrThrow } from "@/lib/util";
+
+type NativeAssetFs = AssetSandboxFs & {
+ readFileSync(path: string): Buffer;
+ writeFileSync(path: string, data: string | Buffer): void;
+};
+
+const assetPathSchema = z
+ .string()
+ .min(1)
+ .max(512)
+ .describe("Relative path below PROJECTCAPTURE_ASSET_ROOT.");
+
+const hytaleFormatSchema = z.enum(HYTALE_FORMAT_IDS);
+const renderModeSchema = z.enum(["default", "emissive", "additive", "layered"]);
+const renderSidesSchema = z.enum(["auto", "front", "double"]);
+
+export const projectCaptureToolDocs: ToolSpec[] = [
+ {
+ name: "create_hytale_project",
+ description: "Creates a Hytale character or prop project with the official format and deterministic texture resolution.",
+ annotations: { title: "Create Hytale Project", destructiveHint: true, openWorldHint: false },
+ parameters: z.object({
+ name: z.string().regex(/^[A-Za-z0-9._-]{1,64}$/),
+ format: hytaleFormatSchema.default("hytale_character"),
+ }),
+ status: STATUS_STABLE,
+ },
+ {
+ name: "hytale_capability_probe",
+ description: "Reports whether the pinned Blockbench/Hytale pipeline and hardened tools are available without disclosing secrets or filesystem roots.",
+ annotations: { title: "Hytale Capability Probe", readOnlyHint: true },
+ parameters: z.object({}),
+ status: STATUS_STABLE,
+ },
+ {
+ name: "hytale_validate_project",
+ description: "Validates Hytale format, nodes, groups, cubes/quads, visibility, shading and texture dimensions.",
+ annotations: { title: "Validate Hytale Project", readOnlyHint: true },
+ parameters: z.object({}),
+ status: STATUS_STABLE,
+ },
+ {
+ name: "hytale_import_texture_png",
+ description: "Imports a PNG only from the configured ProjectCapture asset root.",
+ annotations: { title: "Import Sandboxed Hytale Texture", destructiveHint: true, openWorldHint: false },
+ parameters: z.object({
+ relative_path: assetPathSchema,
+ name: z.string().min(1).max(128).optional(),
+ render_mode: renderModeSchema.default("default"),
+ render_sides: renderSidesSchema.default("auto"),
+ }),
+ status: STATUS_STABLE,
+ },
+ {
+ name: "hytale_save_texture_png",
+ description: "Writes a project texture as PNG below the configured ProjectCapture asset root.",
+ annotations: { title: "Save Sandboxed Hytale Texture", destructiveHint: true, openWorldHint: false },
+ parameters: z.object({
+ texture: z.string().min(1),
+ relative_path: assetPathSchema,
+ }),
+ status: STATUS_STABLE,
+ },
+ {
+ name: "hytale_save_bbmodel",
+ description: "Compiles and writes the editable Blockbench project below the configured ProjectCapture asset root.",
+ annotations: { title: "Save Sandboxed BBModel", destructiveHint: true, openWorldHint: false },
+ parameters: z.object({ relative_path: assetPathSchema }),
+ status: STATUS_STABLE,
+ },
+ {
+ name: "hytale_export_blockymodel",
+ description: "Awaits the official Hytale blockymodel codec and writes its result below the configured asset root.",
+ annotations: { title: "Export Sandboxed Blockymodel", destructiveHint: true, openWorldHint: false },
+ parameters: z.object({
+ relative_path: assetPathSchema,
+ attachment: z.string().min(1).optional().describe("Attachment collection name or UUID."),
+ }),
+ status: STATUS_STABLE,
+ },
+ {
+ name: "hytale_export_blockyanim",
+ description: "Compiles the selected Hytale animation at 60 FPS and writes a blockyanim below the configured asset root.",
+ annotations: { title: "Export Sandboxed Blockyanim", destructiveHint: true, openWorldHint: false },
+ parameters: z.object({
+ relative_path: assetPathSchema,
+ animation: z.string().min(1).optional().describe("Animation name or UUID; defaults to selected."),
+ }),
+ status: STATUS_STABLE,
+ },
+ {
+ name: "hytale_create_attachment",
+ description: "Creates an official blockymodel attachment collection from named root groups.",
+ annotations: { title: "Create Hytale Attachment", destructiveHint: true, openWorldHint: false },
+ parameters: z.object({
+ name: z.string().regex(/^[A-Za-z0-9._-]{1,64}$/),
+ groups: z.array(z.string().min(1)).min(1).max(64),
+ texture: z.string().min(1).optional(),
+ }),
+ status: STATUS_EXPERIMENTAL,
+ },
+];
+
+function requireHytaleProject(): void {
+ if (!Project || !HYTALE_FORMAT_IDS.includes(Format?.id as typeof HYTALE_FORMAT_IDS[number])) {
+ throw new Error("An active Hytale character or prop project is required.");
+ }
+}
+
+function getNativeFs(): NativeAssetFs {
+ // @ts-ignore Blockbench provides permission-gated native modules.
+ const fs = requireNativeModule("fs", {
+ message: "ProjectCapture needs access to its configured asset directory.",
+ detail: "All MCP paths are constrained below PROJECTCAPTURE_ASSET_ROOT.",
+ optional: false,
+ }) as NativeAssetFs | null;
+ if (!fs) throw new Error("Blockbench denied filesystem access.");
+ return fs;
+}
+
+function writeAsset(
+ relativePath: string,
+ extensions: readonly string[],
+ payload: string | Buffer,
+): void {
+ const fs = getNativeFs();
+ const { assetRoot } = readProjectCaptureSecurityConfig();
+ const target = prepareAssetWritePath(assetRoot, relativePath, extensions, fs);
+ fs.writeFileSync(target, payload);
+}
+
+function stringifyCompiledPayload(payload: unknown): string | Buffer {
+ if (typeof payload === "string") return payload;
+ if (payload instanceof ArrayBuffer) return Buffer.from(payload);
+ if (ArrayBuffer.isView(payload)) {
+ return Buffer.from(payload.buffer, payload.byteOffset, payload.byteLength);
+ }
+ return JSON.stringify(payload, null, 2);
+}
+
+function animationToBlockyanim(animation: any): Record<string, unknown> {
+ const fps = 60;
+ const nodeAnimations: Record<string, Record<string, unknown[]>> = {};
+ const channelNames: Record<string, string> = {
+ position: "position",
+ rotation: "orientation",
+ scale: "shapeStretch",
+ visibility: "shapeVisible",
+ uv_offset: "shapeUvOffset",
+ };
+
+ for (const animator of Object.values(animation.animators ?? {}) as any[]) {
+ const node: Record<string, unknown[]> = {};
+ let hasData = false;
+ for (const [channel, targetChannel] of Object.entries(channelNames)) {
+ const output: unknown[] = [];
+ const keyframes = Array.isArray(animator[channel])
+ ? [...animator[channel]].sort((a: any, b: any) => a.time - b.time)
+ : [];
+ for (const keyframe of keyframes) {
+ const dataPoint = keyframe.data_points?.[0] ?? {};
+ let delta: unknown;
+ if (channel === "visibility") {
+ delta = dataPoint.visibility !== false;
+ } else if (channel === "uv_offset") {
+ delta = {
+ x: Math.round(Number(dataPoint.x ?? 0)),
+ y: -Math.round(Number(dataPoint.y ?? 0)),
+ };
+ } else if (channel === "rotation") {
+ const euler = new THREE.Euler(
+ Math.degToRad(keyframe.calc("x")),
+ Math.degToRad(keyframe.calc("y")),
+ Math.degToRad(keyframe.calc("z")),
+ Format.euler_order,
+ );
+ const quaternion = new THREE.Quaternion().setFromEuler(euler);
+ delta = { x: quaternion.x, y: quaternion.y, z: quaternion.z, w: quaternion.w };
+ } else {
+ delta = {
+ x: keyframe.calc("x"),
+ y: keyframe.calc("y"),
+ z: keyframe.calc("z"),
+ };
+ }
+ output.push({
+ time: Math.round(keyframe.time * fps),
+ delta,
+ interpolationType: keyframe.interpolation === "catmullrom" ? "smooth" : "linear",
+ });
+ hasData = true;
+ }
+ if (output.length > 0 || targetChannel === "shapeUvOffset") {
+ node[targetChannel] = output;
+ }
+ }
+ if (hasData) nodeAnimations[animator.name] = node;
+ }
+
+ return {
+ formatVersion: 1,
+ duration: Math.round(animation.length * fps) || fps * 2,
+ holdLastKeyframe: animation.loop === "hold",
+ nodeAnimations,
+ };
+}
+
+export function registerProjectCaptureTools(): void {
+ createTool(projectCaptureToolDocs[0].name, {
+ ...projectCaptureToolDocs[0],
+ async execute({ name, format }) {
+ const modelFormat = Formats[format];
+ if (!modelFormat) {
+ throw new Error(`Official Hytale format "${format}" is unavailable. Install and enable Hytale Models 0.9.1.`);
+ }
+ if (!newProject(modelFormat) || !Project) throw new Error("Blockbench failed to create the Hytale project.");
+ Project.name = name;
+ const blockSize = format === "hytale_prop" ? 32 : 64;
+ Project.texture_width = blockSize;
+ Project.texture_height = blockSize;
+ return JSON.stringify({ name, format, textureSize: [blockSize, blockSize] });
+ },
+ }, projectCaptureToolDocs[0].status);
+
+ createTool(projectCaptureToolDocs[1].name, {
+ ...projectCaptureToolDocs[1],
+ async execute() {
+ const hytalePlugin = Plugins.installed?.find?.((plugin: any) =>
+ plugin.id === "hytale_plugin" && !plugin.disabled
+ );
+ const report = buildCapabilityReport({
+ blockbenchVersion: typeof Blockbench?.version === "string" ? Blockbench.version : null,
+ hytalePluginVersion: hytalePlugin?.version ?? null,
+ formats: Object.keys(Formats ?? {}),
+ codecs: Object.keys(Codecs ?? {}),
+ tools: Object.entries(tools)
+ .filter(([, metadata]) => metadata.enabled)
+ .map(([name]) => name),
+ });
+ return JSON.stringify(report, null, 2);
+ },
+ }, projectCaptureToolDocs[1].status);
+
+ createTool(projectCaptureToolDocs[2].name, {
+ ...projectCaptureToolDocs[2],
+ async execute() {
+ requireHytaleProject();
+ const snapshot = {
+ formatId: Format.id ?? null,
+ nodeCount: Group.all.length + Cube.all.length,
+ groups: Group.all.map((group: any) => ({
+ name: group.name,
+ visibility: group.visibility,
+ })),
+ cubes: Cube.all.map((cube: any) => ({
+ name: cube.name,
+ visibility: cube.visibility,
+ shadingMode: cube.shading_mode,
+ zeroAxes: cube.size().filter((axis: number) => Math.abs(axis) < 1e-9).length,
+ texturedFaces: Object.entries(cube.faces)
+ .filter(([, face]: [string, any]) => face.texture !== null && face.texture !== undefined)
+ .map(([face]) => face),
+ })),
+ meshes: Mesh.all.length,
+ textures: Texture.all.map((texture: any) => ({
+ name: texture.name,
+ width: texture.width,
+ height: texture.height,
+ })),
+ };
+ // Run official/plugin checks as well, but never execute validator buttons.
+ Validator.validate();
+ const result = validateHytaleSnapshot(snapshot);
+ return JSON.stringify({
+ ...result,
+ officialValidator: {
+ errors: Validator.errors.map((item: any) => item.message),
+ warnings: Validator.warnings.map((item: any) => item.message),
+ },
+ }, null, 2);
+ },
+ }, projectCaptureToolDocs[2].status);
+
+ createTool(projectCaptureToolDocs[3].name, {
+ ...projectCaptureToolDocs[3],
+ async execute({ relative_path, name, render_mode, render_sides }) {
+ requireHytaleProject();
+ const fs = getNativeFs();
+ const { assetRoot } = readProjectCaptureSecurityConfig();
+ const source = resolveAssetReadPath(assetRoot, relative_path, [".png"], fs);
+ const texture = new Texture({
+ name: name ?? relative_path.split(/[\\/]/).pop(),
+ render_mode,
+ render_sides,
+ internal: false,
+ }).fromPath(source).add(false);
+ texture.load();
+ texture.fillParticle();
+ Canvas.updateAllFaces();
+ return JSON.stringify({ name: texture.name, uuid: texture.uuid, width: texture.width, height: texture.height });
+ },
+ }, projectCaptureToolDocs[3].status);
+
+ createTool(projectCaptureToolDocs[4].name, {
+ ...projectCaptureToolDocs[4],
+ async execute({ texture, relative_path }) {
+ requireHytaleProject();
+ const targetTexture = findTextureOrThrow(texture);
+ const match = /^data:image\/png;base64,([A-Za-z0-9+/=]+)$/.exec(targetTexture.getDataURL());
+ if (!match) throw new Error("Texture is not available as a PNG data URL.");
+ writeAsset(relative_path, [".png"], Buffer.from(match[1], "base64"));
+ return JSON.stringify({ relativePath: relative_path, bytes: Buffer.byteLength(match[1], "base64") });
+ },
+ }, projectCaptureToolDocs[4].status);
+
+ createTool(projectCaptureToolDocs[5].name, {
+ ...projectCaptureToolDocs[5],
+ async execute({ relative_path }) {
+ requireHytaleProject();
+ const codec = Codecs.project as { compile: () => unknown | Promise<unknown> } | undefined;
+ if (!codec?.compile) throw new Error("Blockbench project codec is unavailable.");
+ const payload = await awaitCompiledPayload(codec.compile());
+ const serialized = stringifyCompiledPayload(payload);
+ writeAsset(relative_path, [".bbmodel"], serialized);
+ return JSON.stringify({ relativePath: relative_path, bytes: Buffer.byteLength(serialized) });
+ },
+ }, projectCaptureToolDocs[5].status);
+
+ createTool(projectCaptureToolDocs[6].name, {
+ ...projectCaptureToolDocs[6],
+ async execute({ relative_path, attachment }) {
+ requireHytaleProject();
+ const codec = Codecs.blockymodel as { compile: (options?: unknown) => unknown | Promise<unknown> } | undefined;
+ if (!codec?.compile) throw new Error("Official Hytale blockymodel codec is unavailable.");
+ let collection: Collection | undefined;
+ if (attachment) {
+ collection = Collection.all.find((item: any) =>
+ item.uuid === attachment || item.name === attachment
+ );
+ if (!collection || collection.export_codec !== "blockymodel") {
+ throw new Error(`Hytale attachment collection "${attachment}" was not found.`);
+ }
+ }
+ const payload = await awaitCompiledPayload(codec.compile(collection ? { attachment: collection } : undefined));
+ const serialized = stringifyCompiledPayload(payload);
+ writeAsset(relative_path, [".blockymodel"], serialized);
+ return JSON.stringify({ relativePath: relative_path, attachment: collection?.name ?? null, bytes: Buffer.byteLength(serialized) });
+ },
+ }, projectCaptureToolDocs[6].status);
+
+ createTool(projectCaptureToolDocs[7].name, {
+ ...projectCaptureToolDocs[7],
+ async execute({ relative_path, animation }) {
+ requireHytaleProject();
+ const selected = animation
+ ? Animation.all.find((item: any) => item.uuid === animation || item.name === animation)
+ : Animation.selected;
+ if (!selected) throw new Error("No matching Hytale animation is selected.");
+ const content = JSON.stringify(animationToBlockyanim(selected), null, 2);
+ writeAsset(relative_path, [".blockyanim"], content);
+ return JSON.stringify({ relativePath: relative_path, animation: selected.name, bytes: Buffer.byteLength(content) });
+ },
+ }, projectCaptureToolDocs[7].status);
+
+ createTool(projectCaptureToolDocs[8].name, {
+ ...projectCaptureToolDocs[8],
+ async execute({ name, groups, texture }) {
+ requireHytaleProject();
+ const resolvedGroups = groups.map((group: string) => findGroupOrThrow(group));
+ const duplicate = new Set(resolvedGroups.map((group) => group.uuid));
+ if (duplicate.size !== resolvedGroups.length) throw new Error("Attachment groups must be unique.");
+ const collection = new Collection({
+ name,
+ children: resolvedGroups.map((group) => group.uuid),
+ export_codec: "blockymodel",
+ visibility: true,
+ }).add() as Collection & { texture?: string };
+ if (texture) collection.texture = findTextureOrThrow(texture).uuid;
+ return JSON.stringify({ name: collection.name, uuid: collection.uuid, groups: resolvedGroups.map((group) => group.name), texture: collection.texture ?? null });
+ },
+ }, projectCaptureToolDocs[8].status);
+}
diff --git a/server/tools/texture.ts b/server/tools/texture.ts
index 14db378..3c8978e 100644
--- a/server/tools/texture.ts
+++ b/server/tools/texture.ts
@@ -32,8 +32,13 @@ export const createTextureParameters = z
height: z.number().min(16).max(4096).default(16),
data: z
.string()
+ .max(12_000_000)
+ .refine(
+ (value) => /^data:image\/png;base64,[A-Za-z0-9+/=]+$/.test(value),
+ "Only inline PNG data URLs are accepted; local files use hytale_import_texture_png and remote URLs are forbidden.",
+ )
.optional()
- .describe("Path to the image file or data URL."),
+ .describe("Inline PNG data URL. Remote URLs and arbitrary file paths are forbidden."),
group: z.string().optional(),
fill_color: colorSchema
.optional()
@@ -239,7 +244,7 @@ export const textureToolDocs: ToolSpec[] = [
annotations: {
title: "Create Texture",
destructiveHint: true,
- openWorldHint: true,
+ openWorldHint: false,
},
parameters: createTextureParameters,
status: STATUS_EXPERIMENTAL,
@@ -395,6 +400,8 @@ export function registerTextureTools() {
fill_color,
group,
layer_name,
+ render_mode,
+ render_sides,
}) {
Undo.initEdit({
textures: [],
@@ -407,20 +414,15 @@ export function registerTextureTools() {
height,
group,
pbr_channel,
+ render_mode,
+ render_sides,
internal: true,
});
if (data) {
- if (data.startsWith("data:image/")) {
- texture.source = data;
- texture.width = width;
- texture.height = height;
- } else {
- texture = texture.fromFile({
- name: data.split(/[\/\\]/).pop() || data,
- path: data.replace(/^file:\/\//, ""),
- });
- }
+ texture.source = data;
+ texture.width = width;
+ texture.height = height;
texture.load();
texture.fillParticle();
diff --git a/tests/projectcapture-hytale.test.ts b/tests/projectcapture-hytale.test.ts
new file mode 100644
index 0000000..bcfbcab
--- /dev/null
+++ b/tests/projectcapture-hytale.test.ts
@@ -0,0 +1,87 @@
+import { describe, expect, test } from "bun:test";
+import {
+ awaitCompiledPayload,
+ buildCapabilityReport,
+ hytaleQuadBounds,
+ keyframeDataPoint,
+ validateHytaleSnapshot,
+} from "../lib/projectcaptureHytale";
+
+describe("Hytale geometry and animation helpers", () => {
+ test("maps every quad normal to the correct zero axis and textured face", () => {
+ expect(hytaleQuadBounds([1, 2, 3], "+X", [4, 5])).toEqual({
+ from: [1, 2, 3], to: [1, 7, 7], face: "east",
+ });
+ expect(hytaleQuadBounds([1, 2, 3], "-Y", [4, 5])).toEqual({
+ from: [1, 2, 3], to: [5, 2, 8], face: "down",
+ });
+ expect(hytaleQuadBounds([1, 2, 3], "-Z", [4, 5])).toEqual({
+ from: [1, 2, 3], to: [5, 7, 3], face: "north",
+ });
+ });
+
+ test("uses the official visibility property and vector data points", () => {
+ expect(keyframeDataPoint("visibility", false)).toEqual({ visibility: false });
+ expect(keyframeDataPoint("position", [1, 2, 3])).toEqual({ x: 1, y: 2, z: 3 });
+ expect(keyframeDataPoint("scale", 2)).toEqual({ x: 2, y: 2, z: 2 });
+ expect(() => keyframeDataPoint("visibility", [1, 2, 3])).toThrow();
+ });
+
+ test("awaits asynchronous codec output", async () => {
+ expect(await awaitCompiledPayload(Promise.resolve("compiled"))).toBe("compiled");
+ });
+});
+
+describe("Hytale validator and capability probe", () => {
+ test("accepts a minimal character snapshot", () => {
+ const result = validateHytaleSnapshot({
+ formatId: "hytale_character",
+ nodeCount: 2,
+ groups: [{ name: "root", visibility: true }],
+ cubes: [{
+ name: "body",
+ visibility: true,
+ shadingMode: "flat",
+ zeroAxes: 0,
+ texturedFaces: ["north", "south", "east", "west", "up", "down"],
+ }],
+ meshes: 0,
+ textures: [{ name: "creature.png", width: 64, height: 64 }],
+ });
+ expect(result).toEqual({ valid: true, errors: [], warnings: [] });
+ });
+
+ test("rejects ambiguous groups, invalid quads, meshes and texture sizes", () => {
+ const result = validateHytaleSnapshot({
+ formatId: "hytale_character",
+ nodeCount: 256,
+ groups: [
+ { name: "leg", visibility: true },
+ { name: "leg", visibility: "yes" },
+ ],
+ cubes: [{
+ name: "fin",
+ visibility: true,
+ shadingMode: "smooth",
+ zeroAxes: 1,
+ texturedFaces: ["north", "south"],
+ }],
+ meshes: 1,
+ textures: [{ name: "bad.png", width: 32, height: 65 }],
+ });
+ expect(result.valid).toBe(false);
+ expect(result.errors.length).toBeGreaterThanOrEqual(6);
+ });
+
+ test("reports readiness only when all pinned capabilities exist", () => {
+ const report = buildCapabilityReport({
+ blockbenchVersion: "5.0.7",
+ hytalePluginVersion: "0.9.1",
+ formats: ["hytale_character", "hytale_prop"],
+ codecs: ["blockymodel"],
+ tools: ["hytale_save_bbmodel", "hytale_save_texture_png", "hytale_export_blockyanim"],
+ });
+ expect(report.ready).toBe(true);
+ expect(report.security.arbitraryEvaluation).toBe(false);
+ });
+});
diff --git a/tests/projectcapture-security.test.ts b/tests/projectcapture-security.test.ts
new file mode 100644
index 0000000..2651fc1
--- /dev/null
+++ b/tests/projectcapture-security.test.ts
@@ -0,0 +1,124 @@
+import { afterEach, describe, expect, test } from "bun:test";
+import {
+ mkdirSync,
+ mkdtempSync,
+ readFileSync,
+ realpathSync,
+ rmSync,
+ symlinkSync,
+ writeFileSync,
+ existsSync,
+ lstatSync,
+} from "node:fs";
+import { tmpdir } from "node:os";
+import path from "node:path";
+import {
+ isAuthorizedBearer,
+ prepareAssetWritePath,
+ readProjectCaptureSecurityConfig,
+ resolveAssetReadPath,
+} from "../lib/projectcaptureSecurity";
+import {
+ isProjectCaptureToolAllowed,
+ PROJECTCAPTURE_FORBIDDEN_TOOLS,
+} from "../lib/projectcaptureAllowlist";
+
+const tempRoots: string[] = [];
+const nodeFs = {
+ existsSync,
+ lstatSync,
+ realpathSync,
+ mkdirSync,
+};
+
+function tempRoot(): string {
+ const root = mkdtempSync(path.join(tmpdir(), "projectcapture-assets-"));
+ tempRoots.push(root);
+ return root;
+}
+
+afterEach(() => {
+ for (const root of tempRoots.splice(0)) {
+ rmSync(root, { recursive: true, force: true });
+ }
+});
+
+describe("ProjectCapture security configuration", () => {
+ test("requires a long token and absolute asset root", () => {
+ const config = readProjectCaptureSecurityConfig({
+ PROJECTCAPTURE_BLOCKBENCH_TOKEN: "abcdefghijklmnopqrstuvwxyz0123456789-TEST",
+ PROJECTCAPTURE_ASSET_ROOT: "/tmp/projectcapture-assets",
+ });
+ expect(config.host).toBe("127.0.0.1");
+ expect(config.assetRoot).toBe("/tmp/projectcapture-assets");
+ });
+
+ test("rejects missing, short, whitespace and relative secrets/config", () => {
+ expect(() => readProjectCaptureSecurityConfig({})).toThrow();
+ expect(() => readProjectCaptureSecurityConfig({
+ PROJECTCAPTURE_BLOCKBENCH_TOKEN: "short",
+ PROJECTCAPTURE_ASSET_ROOT: "/tmp/assets",
+ })).toThrow();
+ expect(() => readProjectCaptureSecurityConfig({
+ PROJECTCAPTURE_BLOCKBENCH_TOKEN: "abcdefghijklmnopqrstuvwxyz 0123456789",
+ PROJECTCAPTURE_ASSET_ROOT: "/tmp/assets",
+ })).toThrow();
+ expect(() => readProjectCaptureSecurityConfig({
+ PROJECTCAPTURE_BLOCKBENCH_TOKEN: "abcdefghijklmnopqrstuvwxyz0123456789",
+ PROJECTCAPTURE_ASSET_ROOT: "relative/assets",
+ })).toThrow();
+ });
+
+ test("accepts only the exact bearer token", () => {
+ const token = "abcdefghijklmnopqrstuvwxyz0123456789";
+ expect(isAuthorizedBearer(`Bearer ${token}`, token)).toBe(true);
+ expect(isAuthorizedBearer(`bearer ${token}`, token)).toBe(false);
+ expect(isAuthorizedBearer(`Bearer ${token}x`, token)).toBe(false);
+ expect(isAuthorizedBearer(undefined, token)).toBe(false);
+ });
+});
+
+describe("asset-root sandbox", () => {
+ test("creates safe parents and resolves an allowed output", () => {
+ const root = tempRoot();
+ const target = prepareAssetWritePath(root, "creatures/test/model.bbmodel", [".bbmodel"], nodeFs);
+ expect(target).toBe(path.join(realpathSync(root), "creatures/test/model.bbmodel"));
+ expect(lstatSync(path.dirname(target)).isDirectory()).toBe(true);
+ });
+
+ test("rejects traversal, absolute paths and wrong extensions", () => {
+ const root = tempRoot();
+ expect(() => prepareAssetWritePath(root, "../outside.bbmodel", [".bbmodel"], nodeFs)).toThrow();
+ expect(() => prepareAssetWritePath(root, "/tmp/outside.bbmodel", [".bbmodel"], nodeFs)).toThrow();
+ expect(() => prepareAssetWritePath(root, "model.js", [".bbmodel"], nodeFs)).toThrow();
+ });
+
+ test("rejects a symlinked parent and target", () => {
+ const root = tempRoot();
+ const outside = tempRoot();
+ symlinkSync(outside, path.join(root, "linked"));
+ expect(() => prepareAssetWritePath(root, "linked/model.bbmodel", [".bbmodel"], nodeFs)).toThrow();
+
+ writeFileSync(path.join(outside, "real.png"), "png");
+ symlinkSync(path.join(outside, "real.png"), path.join(root, "texture.png"));
+ expect(() => resolveAssetReadPath(root, "texture.png", [".png"], nodeFs)).toThrow();
+ });
+
+ test("reads only a regular in-root PNG", () => {
+ const root = tempRoot();
+ writeFileSync(path.join(root, "texture.png"), "png-data");
+ const result = resolveAssetReadPath(root, "texture.png", [".png"], nodeFs);
+ expect(readFileSync(result, "utf8")).toBe("png-data");
+ });
+});
+
+describe("tool policy", () => {
+ test("allows reviewed Hytale tools and denies every dangerous tool", () => {
+ expect(isProjectCaptureToolAllowed("create_hytale_project")).toBe(true);
+ expect(isProjectCaptureToolAllowed("hytale_export_blockyanim")).toBe(true);
+ for (const tool of PROJECTCAPTURE_FORBIDDEN_TOOLS) {
+ expect(isProjectCaptureToolAllowed(tool), tool).toBe(false);
+ }
+ expect(isProjectCaptureToolAllowed("unknown_future_tool")).toBe(false);
+ });
+});
diff --git a/ui/settings.ts b/ui/settings.ts
index 93b3a0a..8451115 100644
--- a/ui/settings.ts
+++ b/ui/settings.ts
@@ -30,14 +30,6 @@ export function settingsSetup() {
category,
icon: "webhook",
}),
- new Setting("mcp_prompt_cdn_enabled", {
- name: tl("mcp.settings.prompt_cdn_name"),
- description: tl("mcp.settings.prompt_cdn_desc"),
- type: "toggle",
- value: true,
- category,
- icon: "cloud_download",
- }),
new Setting("mcp_session_timeout", {
name: tl("mcp.settings.session_timeout_name"),
description: tl("mcp.settings.session_timeout_desc"),