1742 lines
63 KiB
Diff
1742 lines
63 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,202 @@
|
|
+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;
|
|
+ };
|
|
+ realpathSync(path: string): string;
|
|
+ mkdirSync(path: string, options?: { mode?: number }): unknown;
|
|
+}
|
|
+
|
|
+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 = fs.lstatSync(root);
|
|
+ if (rootStat.isSymbolicLink() || !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 = fs.lstatSync(cursor);
|
|
+ if (stat.isSymbolicLink() || !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 = fs.lstatSync(candidate);
|
|
+ if (stat.isSymbolicLink() || !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 = fs.lstatSync(lexicalCandidate);
|
|
+ if (stat.isSymbolicLink() || !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"),
|