Add panel maximize/restore, template tiles with atomic rectangular graph creation, and tabs for existing preferences. Keep main's graph, explorer, library and embedded editor implementations intact, and scope template textures to each app. Validated with Rust 1.96.0: engine and UI release suites, 14 offscreen UI tests and visual inspection, workspace Clippy, layers, wasm, native build and live maximize/restore checks.
18 KiB
Control protocol and MCP
Last reviewed: 2026-11-02 · Last updated: 2026-11-03 · Change: minor (
material_exportMCP tool) · Target: the professional node-based material workflow
Agents drive MaterialCraft three ways, all dispatching the same engine commands:
- MCP (
materialcraft-cli mcp): headless (an in-process session, files only below--read-root/--write-root, also spelled--automation-read-root/--automation-write-rootas in the other crafting apps;servetakes them too) or bridged to the running app (--bridge <port>). - JSON lines (
materialcraft-cli serve): the same methods on stdio, or on127.0.0.1:<port>behind a token. - The app's control channel (
materialcraft --control <port>): one JSON request per line,{"id", "method", "params"}→{"id", "ok": true, "result"}or{"id", "ok": false, "error"}. The first request must be{"method":"auth","params":{"token":"…"}}with the token fromMATERIALCRAFT_CONTROL_TOKEN(16 to 1024 printable ASCII characters) or the file<temp>/materialcraft-control-<port>.token, a fresh random 256-bit token written after the port is bound. On Unix the file is created new with mode 0600 (owner only); if an existing file there cannot be replaced, the control server stays off andMATERIALCRAFT_CONTROL_TOKENis the alternative. Clients (materialcraft-cli mcp --bridge) refuse a token file that is a symlink, not a regular file, not owned by them, or accessible to group or others. A connection gets one authentication attempt, within 10 s; a wrong token, any other first request or malformed JSON closes it. At most 16 connections are served at once. Whoever holds the token has full engine-command authority, including host file access. SetMATERIALCRAFT_NO_PREFS=1for scripted runs so the user's layout is left alone.
Methods
| Method | Where | What |
|---|---|---|
ping |
all | "pong" |
command.list {filter?} |
all | every command: id, label, menu, shortcut, params doc, enabled |
command.run {id, params?} |
all | run one command (programmatic: never opens a dialog) |
command.batch {steps} |
headless | run [{id, params}] in order, stop at the first error |
session.inspect |
all | package, graphs, the open graph's nodes (def, params, bindings, base, ports, cook error/size/mean/ms), wires, frames, comments, pins, exposed inputs, views, history |
package.open {path} / package.save {path?} |
headless | workspace-scoped package I/O |
bitmap.import {path, name?} |
headless | embed an image as a resource |
outputs.export {dir, format?, pattern?, depth?, size?, graph?} |
headless | cook at full size and write every output |
material.export {path, graph?, size?, values?} |
headless | write a portable .mcmat below the write root |
render {view?, width?, height?, yaw?, pitch?, scene?, node?, port?, full?, tiling?, hq?, displacement?} |
headless | PNG (base64) of the 3D view or of one node; hq is the beauty render (displaced mesh, ray-traced AO, shadowed key light, 3×3 supersampling), displacement its depth in scene radii |
ui.inspect |
app | UI state, the graph view rectangle, every node's screen rectangle, every port's identifier, display label and screen position, fps, toasts |
ui.menu.list / ui.menu.invoke {id} |
app | the menu catalog; behave like a click |
ui.panel.maximize {panel} |
app | maximize an open, active panel: Graph, Explorer, Library, View3d, View2d, Properties, Console, or History; restore with ui.menu.invoke {"id":"ui.unmaximize"} |
ui.set {...} |
app | recursively merge fields into the UI state |
ui.input {events} |
app | synthetic input, below |
view.screenshot {view?: "3d"|"2d"} |
app | PNG of the 3D or 2D view |
ui.screenshot |
app | PNG of the whole window |
ui.resize {width, height} |
app | window size in points |
Synthetic input (ui.input)
Events are applied one frame at a time, so egui sees real presses, drags and releases. Positions are
window points (take them from ui.inspect's nodes and ports).
{"method": "ui.input", "params": {"events": [
{"type": "drag", "from": [512, 140], "to": [600, 220], "steps": 12},
{"type": "click", "pos": [700, 300], "button": "left", "modifiers": ["ctrl"]},
{"type": "doubleClick", "pos": [640, 180]},
{"type": "scroll", "pos": [640, 300], "delta": [0, -120]},
{"type": "key", "key": "Space"},
{"type": "text", "text": "perlin"},
{"type": "key", "key": "Enter"}
]}}
Types: move, down, up, click, doubleClick, drag, scroll, key (egui key names:
A, Enter, Space, Delete, F5, …), text. Buttons: left, right, middle.
ui.inspect.maximizedPanel reports the temporary maximized panel, or null. Maximizing
preserves the saved workspace. Shift+Space toggles maximization under the pointer; text inputs,
popups, modal dialogs and pointer drags retain their input.
Key commands
| Command | Params |
|---|---|
file.new |
{kind?, template?, name?, size?, width?, height?}; material width/height override size per axis (default 1024 px), rounded down to powers of two in 16..8192; creation is one undo step. Templates from graph.templates: empty, pbr, atomics |
graph.node.edit |
{} resolves the selected FX-Map, processor or local graph instance to {kind, node, parameter?, graph?}; use ui.menu.invoke to enter it in the graph panel |
graph.pattern.set |
{id, pattern} replaces an FX-Map subgraph with cycle validation and undo |
graph.node.add |
{def, at?, params?, connectFrom?: {node, port?}, label?} |
graph.connect / graph.disconnect |
{from, fromPort?, to, toPort?} / {to, toPort} |
graph.wire.insertDot |
{to, toPort, at?}: split the wire feeding to.toPort with a reroute Dot node at at (graph point, default midway), selected |
graph.wire.swap / graph.wire.delete |
{wires: [{to, toPort}, ...]}: swap (two) or reverse (more, in input order) where the wires come from / delete them, one undo step each |
graph.node.param |
{id?, param, value}: numbers, [x, y], "#rrggbb", enum index, curve [[x, y], …], gradient [[pos, color], …], or a typed value {"t": "function", "v": {...}} |
graph.node.base |
{id?, sizeMode?, size?, format?, seed?, tiling?} |
graph.exposed.add / graph.exposed.set |
{id, label?, default?, bind?: {node, param}} (with bind, id may be omitted: the input is named after the parameter, opacity, opacity_1…) / {id, value} |
graph.node.function |
{id?, param, function} drives a parameter by a function graph of the graph's inputs (dynamic parameter); {id?, param, remove: true} removes it |
graph.preset.save / .apply / .delete |
{name}: store, restore or remove the current input values |
graph.createFromSelection |
{name?, ids?}: move nodes into a sub-graph and replace them with an instance |
view.2d / view.3d |
which node the 2D view shows, channel, tiling, histogram; scene, environment, camera, tiling, heightScale (how far the height output raises the mesh, in mesh radii) and heightLevel (the height that stays on the surface; 0 = the relief rises out of it), normalYFlip (interpret DirectX normal maps; default false/OpenGL), transparency (see through where the alpha is below one: the opacity output, else the base color's alpha), alphaView (show the alpha channel) |
view.3d.loadMesh |
{path} shows an OBJ, glTF or GLB mesh (scene custom) |
library.list |
{filter?, category?}: every node with ports and parameters |
3D material assignment
view.3d.outputs {graph?, reset?} applies a material graph's image outputs by their usage.
graph accepts an index or name and defaults to the open graph. Ordinary application preserves
unmatched channel assignments; reset: true clears them while preserving the scene, camera and lighting.
Outputs with unsupported usages or scalar/vector results cannot supply material textures.
view.3d.assign {graph?, node?, port?, channel, reset?} assigns an individual image to baseColor,
normal, roughness, metallic, height, ambientOcclusion, emissive or opacity.
The node defaults to the selection only in the open graph. The port accepts an identifier or index;
terminal Output nodes use output or index 0. Assignments refer to their source graph and named port,
update when that source changes, and remain active while editing a Function Graph.
view.3d.clear {} removes assignments without resetting the scene.
Opening a material graph automatically previews its output usages. Selecting or previewing a node
in 2D does not reassign the 3D material. Dragging a graph from Explorer onto the 3D view applies its outputs without opening the graph.
Canvas and Explorer context menus use the same assignment
commands; the 3D View's Material menu lists its channel sources. A Height-only output affects height
sampling, not Base Color; assign that image to baseColor to inspect its grayscale pattern on the mesh.
session.inspect.material3d reports assignments and resolved channels, including source graph/node/port,
image dimensions and mean values. Assignment commands leave the package and document undo history
unchanged. Document undo restores the associated source references; replacing a package clears them.
Function graphs
file.new {"kind": "function", "name": "Calculation"} creates a standalone Function Graph.
Omit kind, or use "material", for a Graph. graph.function.set {"function": {...}} replaces
the active standalone function through one validated, undoable command.
Both graph.function.set and graph.function.edit accept an optional target: {"kind":"graph"}
(the default), {"kind":"parameter","node":12,"parameter":"function"},
{"kind":"dynamic","node":12,"parameter":"opacity"}, or
{"kind":"pattern","owner":12,"node":3,"parameter":"patternrotation"}.
graph.function.edit accepts edit: {action: add|move|connect|value|output, ...}.
For example, {"edit":{"action":"add","operation":"mul","at":[256,0],"snap":true}}
creates and returns a node ID using the same defaults as library and context-menu insertion.
graph.pattern.edit accepts the FX-Map owner id and
edit: {action: add|move|connect|disconnect|root|value, ...}.
Use command.list for command descriptions and the serialized FunctionEdit/PatternEdit types
in crates/engine/src/ for the action fields. Value edits are individual undo steps by default;
merge: true groups repeated updates to the same target and value until edit.finish or another
edit ends the group. Idle UI frames preserve explicitly requested automation groups.
ui.menu.invoke sends selection actions to the focused editor, including embedded Function
windows. ui.inspect.subgraph.target identifies that Function target; its canvas rectangles and
ports correspond to the same editor. Raw command.run calls retain their explicit command targets.
Selection and clipboard commands (graph.select, edit.selectAll, edit.copy, edit.cut,
edit.paste, edit.duplicate, edit.delete, their without-link/relink variants and upstream/downstream
selection) also work without a UI. They infer the active standalone Function Graph. To address an
embedded graph, pass function: <FunctionGraphTarget> or pattern: <owner node id> and explicit
ids for selection-dependent operations. edit.selectAll returns those IDs. An optional numeric
graph guards against the owning graph no longer being active. graph.align accepts the same
Function and FX-Map targets with explicit IDs.
Results include ids and selected; copy/cut also return copied and a typed clip accepted by
paste. The session retains the most recent embedded clipboard. Cross-target paste keeps internal
links and drops external links. Standalone commands update the session selection; embedded commands
return their selection without changing the parent selection. Invalid operations preserve the
document, clipboard, selection and undo history. Creation and all function/pattern setters share
cycle, capacity, ID and position validation.
A function has nodes and an optional output node id. Each node has id, native op,
inputs (source ids or null for unwired ports), optional value, type_code and pos.
For example, const_float1 supplies a scalar and mul multiplies its a and b inputs.
The catalog in crates/nodes/src/function/catalog.rs defines all native operation and port ids.
Processors and dynamic parameters use the same function representation. Graph validation and
execution use bounded iterative traversal and reject cycles.
graph.annotation.select {"kind": "frame"|"comment", "id": ...} selects an annotation.
graph.frame.edit accepts title, titleVisible, description, color and rect.
graph.frame.move {"id": ..., "by": [dx, dy], "snapToGrid": true} moves enclosed nodes,
comments, nested frames and pins together. graph.comment.edit accepts text, at and size.
session.inspect includes annotation selection and the standalone function; ui.inspect
includes frame and comment rectangles for synthetic input.
Portable materials (TextureCraft)
A .mcmat portable material is a zip: material.json (manifest), maps/<usage>.png and
source.sbs (the package). TextureCraft applies it as a fill layer and keeps the exposed inputs
editable, re-cooking through materialcraft-cli. The format is a convention the two apps share;
each has its own reader and writer (crates/formats/src/material.rs here), so keep them in step.
{ "format": "craft-material", "version": 2, "name": "Brick wall", "graph": "Brick_wall", "size": [2048, 2048],
"maps": [{"usage": "baseColor", "file": "maps/baseColor.png", "colorSpace": "srgb"}],
"parameters": [{"id": "wear", "label": "Wear", "default": {"t": "float", "v": 0.5}, "min": 0, "max": 1,
"value": {"t": "float", "v": 0.8}}],
"inputs": [{"identifier": "curvature", "kind": "gray"}],
"presets": [{"name": "Worn", "values": {"wear": {"t": "float", "v": 1.0}}}],
"source": "source.sbs" }
- Usages come from the shared
ComponentUsagecatalog (the first output of each, excludingany). The catalog includes PBR, diffuse/specular, coat, sheen, scattering and user channels. Color maps are 8-bit sRGB PNGs; data maps are 16-bit linear. Each map records its color space. Additional usage identifiers do not change the manifest version; consumers may ignore usages they do not support. Normals are tangent space, OpenGL (Y+); height is 0..1 with 0.5 at the surface. The 3D preview supports base color (includingdiffuse), normal, roughness, metallic, height, ambient occlusion, emissive and opacity; other usages remain available for SBS and exports. - Parameters are the exposed inputs other apps can set (float, float2-4, int, int2, bool, color,
enum), values in the typed form
{"t", "v"}; colors are linear RGBA. - Inputs are the graph's input nodes. Mesh maps feed them by identifier:
ambientOcclusion,curvature,thickness,position,worldNormal,normal,height,id,bentNormal,opacity,colorMap,worldDirection. - Readers ignore unknown fields and require manifest
version2.
| Command | Params |
|---|---|
file.exportMaterial |
{path, graph?, size? (px), values? {input: value}, inputs? {identifier: image path}}: cook and write a .mcmat. TextureCraft runs materialcraft-cli run source.sbs file.exportMaterial {...} to re-cook at its texture set's resolution with its own values and baked mesh maps |
file.sendToTextureCraft |
{graph?}: export into TextureCraft's library folder (Documents/TextureCraft/library), where its shelf lists it |
graph.input.bind / graph.input.unbind |
{identifier, resource? | path?, linear?} / {identifier}: a preview image for an input node (a mesh map exported from TextureCraft), saved in the package |
file.open |
opens native .sbs packages, and a .mcmat's source package, on its graph, with no file name. Returns warnings: what an SBS file holds that MaterialCraft can't read yet (missing nodes are kept as placeholders); also in session.inspect as package.warnings |
file.save |
{path?}: native SBS; .sbs is appended when the path does not end in .sbs |
MCP tools
session_inspect, command_list, command_run, command_batch, library_list, graph_new,
node_add, node_connect, node_param, package_open, package_save, bitmap_import,
outputs_export, material_export, view_render (headless) and, bridged to the app, view_screenshot,
ui_screenshot, ui_inspect, ui_menu_invoke, ui_input. package_open opens .mcmat files
too, and material_export {path, graph?, size?, values?} writes one below --write-root (path is
relative, the parent folder must exist, values are exposed input values). The workspace sandbox
still refuses file.* commands, so a sandboxed agent exports through the tool, not file.exportMaterial;
mesh-map inputs stay a CLI feature.
While an FX-Map subgraph is open, ui.inspect.subgraph identifies its owner and selection. Canvas
node IDs and ports refer to that embedded graph. ui.menu.invoke with ui.graph.parent returns
to the parent canvas.
Automation command requests each form a separate undo step. Embedded graph edits may explicitly
pass merge: true to continue one gesture; an ordinary request ends that merge. UI slider gestures
still coalesce until release. material_export uses the headless write-root capability and does not
expose unrestricted engine file paths.
Bitmap Color space selects sRGB or Linear. graph.input.bind accepts linear: true for normal,
height and other data maps. Input preview bindings persist in native SBS graph options with the
materialcraft.previewImage. and materialcraft.previewLinear. prefixes; these options describe
MaterialCraft editor previews and do not establish external application interoperability.