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( // 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; const server = getServer(); @@ -214,7 +219,7 @@ export function createTool( 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([ + // 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([ + "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 = { + "+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 { + 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(value: T | Promise): Promise { + 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(); + 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 = 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 sessionConfig?: Partial } ): [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 @@ +/// +/// + +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 { + const fps = 60; + const nodeAnimations: Record> = {}; + const channelNames: Record = { + position: "position", + rotation: "orientation", + scale: "shapeStretch", + visibility: "shapeVisible", + uv_offset: "shapeUvOffset", + }; + + for (const animator of Object.values(animation.animators ?? {}) as any[]) { + const node: Record = {}; + 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 } | 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 } | 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"),