18 KiB
Control protocol, MCP and CLI
Everything TextureCraft does is an engine command with a stable id (layer.add.fill,
bake.run, edit.undo, …). Agents and scripts reach the same commands four ways:
| Surface | Start it with | Use it for |
|---|---|---|
| Control channel | texturecraft --control <port> |
Drive the running desktop app: commands, inspect, menus, screenshots |
| MCP server | texturecraft-cli mcp |
AI agents (Claude Code, Claude Desktop, any MCP client), headless or bridged to the app |
| JSON-lines server | texturecraft-cli serve [--port <port>] |
Scripts that keep one headless session open |
| CLI subcommands | texturecraft-cli info/run/batch/bake/export/render/new |
One-shot jobs in shells and pipelines |
Programmatic calls never open dialogs: a command runs with its defaults. Only ui.menu.invoke
behaves like a menu click. session.inspect shows every layer, mask, effect, fill source and
painted tile count, so an agent can verify its work without a screenshot.
effect.histogram {effect, channel?, bins?} counts the values an effect reads (a levels
effect's input), so an agent can pick its input range from the data. effect.move {effect, index} reorders an effect within its stack (0 = bottom) and effect.duplicate {effect} copies
one just above itself. effect.copy {effect?} and effect.cut {effect?} put an effect on the
layer clipboard, and effect.paste {layer?, target?} adds it to another layer's content or mask.
edit.undo {steps?} and edit.redo {steps?} go back or forward several steps at once (as far
as the history reaches); edit.history lists the steps done and the undone ones, next first.
Any command takes merge: true to fold its edit into the previous undo step when that step has
the same label: a drag that applies a value on every frame stays one step.
Control channel (desktop app)
texturecraft --control 7878
# stderr: texturecraft: control token written to <temp>/texturecraft-control-7878.token
Loopback TCP (127.0.0.1 only), one JSON object per line each way. Requests are
{"id", "method", "params"}; replies are {"id", "ok": true, "result"} or
{"id", "ok": false, "error"}. Requests are limited to 8 MiB.
Authentication. The first request on every connection must be
{"id": 0, "method": "auth", "params": {"token": "<token>"}}
The token is TEXTURECRAFT_CONTROL_TOKEN when that is set (16 to 1024 printable ASCII
characters; surrounding whitespace is ignored), otherwise a fresh random 256-bit token the app
writes to <temp>/texturecraft-control-<port>.token (<temp> is %TEMP% on Windows, $TMPDIR
or /tmp elsewhere). On Unix the file is created new and readable by its owner only; when an
existing file there cannot be replaced (another user's, in a shared /tmp), the control server
stays off: set TEXTURECRAFT_CONTROL_TOKEN instead. Clients (texturecraft-cli mcp --bridge)
only read a token file that is a regular file owned by them with no group or other permissions.
A connection must authenticate within 10 seconds. Any other first request, or a wrong token, is refused and the connection closed. At most 16 connections are served at once.
Scripted runs (agents, recordings, tests) can set TEXTURECRAFT_NO_PREFS (any value) so the app
neither reads nor overwrites the user's saved layout and settings: it starts from the defaults and
keeps its state in a throwaway file.
| Method | Params | Result |
|---|---|---|
ping |
"pong" |
|
command.run |
{id, params?} |
the command's JSON result |
command.list |
{filter?} |
[{id, label, menu, shortcut, params, enabled}] |
session.inspect |
project, mesh, texture sets, layer stack, tool, camera, display, history | |
ui.inspect |
UI state, viewport rect, fps, bake progress, toasts, titleBar (who draws the title bar this run: app, integrated on macOS, or system) |
|
ui.menu.list |
the menu catalog: {menu, submenu, label, id, shortcut, implemented, enabled} |
|
ui.menu.invoke |
{id} |
behaves like a menu click (may open a window) |
ui.set |
fields of the UI state, e.g. {"layout": "Only3D"}; dock is the panel layout, a craft-ui docking Workspace ({version, layout: {root, floating}, hidden}; root is a tree of Split, Tabs and Stack nodes over the panels Viewport, Assets, TextureSetList, Layers, TextureSetSettings, Properties, Display, Shader, History and Log; hidden lists where each closed panel was, so reopening it puts it back; a layout that does not read or has no docked Viewport becomes the default), prefs the settings ({"language": "ja", "viewport_texture": 1, "brightness": "light", "accent": "blue"}; missing fields take their defaults; brightness is dark, mediumDark (default), mediumLight or light, accent copper (default), coral, blue, violet, green, amber or pink, applied on the next frame; system_title_bar true has the system draw the title bar from the next start), keymap the custom shortcuts ({"file.save": "F5"}, "" unbinds) |
null |
viewport.screenshot |
{view?: "3d" | "2d"} |
{width, height, png} (PNG, base64) |
ui.screenshot |
{width, height, png} of the whole window, after the next frame |
|
ui.resize |
{width, height} |
Sets the window's inner size in points (640–7680), leaving maximized state; for recordings and screenshots at a fixed size |
debug.paintTest |
{from: [x, y], to: [x, y], frames?: 120} |
Paints a straight stroke through the real frame loop (viewport pixels, one point per frame) for performance work |
debug.frames |
Frame timing since the last call (then cleared): {frames, intervalMs, uiMs, syncMs, logicMs} each {avg, p95, max}; syncMs is compositing plus display tile conversion, logicMs input, control requests and scripted strokes before the UI is built; worst breaks down the slowest frame; strokeEnd lists the frame that committed a debug.paintTest stroke and the one after it (null without one) |
Example session:
> {"id":0,"method":"auth","params":{"token":"3f9c…"}}
< {"id":0,"ok":true}
> {"id":1,"method":"command.run","params":{"id":"layer.add.fill","params":{"color":"#336699","roughness":0.2}}}
< {"id":1,"ok":true,"result":{"layer":7}}
> {"id":2,"method":"command.run","params":{"id":"mask.add.black"}}
< {"id":2,"ok":true,"result":null}
> {"id":3,"method":"viewport.screenshot","params":{"view":"3d"}}
< {"id":3,"ok":true,"result":{"width":1210,"height":788,"png":"iVBORw0KGgo…"}}
A minimal client (Python, for illustration):
import json, os, socket, tempfile
port = 7878
token = os.environ.get("TEXTURECRAFT_CONTROL_TOKEN") or open(
os.path.join(tempfile.gettempdir(), f"texturecraft-control-{port}.token")).read().strip()
s = socket.create_connection(("127.0.0.1", port)); f = s.makefile("rw")
def call(method, params=None, _id=[0]):
_id[0] += 1
f.write(json.dumps({"id": _id[0], "method": method, "params": params or {}}) + "\n"); f.flush()
reply = json.loads(f.readline())
if not reply["ok"]: raise RuntimeError(reply["error"])
return reply.get("result")
call("auth", {"token": token})
print(call("command.run", {"id": "layer.add.fill", "params": {"color": [0.8, 0.1, 0.1]}}))
MCP server
texturecraft-cli mcp speaks MCP on stdin/stdout (stdout carries only protocol messages).
- Headless (default): an in-process session that starts on the material ball (1024).
Files are reachable only below the roots given at launch, as forward-slash paths relative to
them:
--read-root <dir>forproject_open/mesh_import/mesh_reimport/bake_mesh_add,--write-root <dir>forproject_save/material_save/textures_export/mesh_export(--automation-read-root/--automation-write-root, as in the other crafting apps, are the same flags). Without roots there is no file access..., absolute paths, drive letters, device names and symlink or junction escapes are refused. - Bridge:
--bridge <port>forwards to a runningtexturecraft --control <port>(token from--token,TEXTURECRAFT_CONTROL_TOKENor the app's token file). The app reads and writes its own files, so the path tools are unavailable there;project_savewithout a path invokes File › Save.
Through command_run, every file.* command but file.close and file.removeUnused (and
file.reimportMesh without a path) is refused, and so is any command given a path or dir (resource.import,
material.save, material.load, …), in both modes: use the workspace tools below. resource.text
takes a font family, not a path: it reads only the system's font folders.
| Tool | Params | Notes |
|---|---|---|
session_inspect |
The whole session as JSON | |
command_list |
{filter?} |
Command ids, labels, params docs, enabled now |
command_run |
{id, params?} |
Any command by id |
command_batch |
{steps: [{id, params?}]} |
Stops at the first error: {completed, results, failed: {index, id, error}} (a tool error) |
project_new |
{mesh?, resolution?, name?, template?} |
Built-in mesh: material_ball, sphere, cube, rounded_cube, cylinder, torus, plane, crate; template names a project template (project.templates) |
project_open |
{path} |
.tcraft or a mesh file, relative to the read root |
project_save |
{path?} |
.tcraft (appended if missing), relative to the write root |
mesh_import |
{path, resolution?} |
New project from .glb/.gltf/.fbx/.dae/.usd/.usda/.usdc/.usdz/.abc/.obj/.ply/.stl; a .gltf's buffers and images and a loose USD layer's textures are read from its folder |
mesh_reimport |
{path?, preserveStrokes?} |
Reimport the mesh from a file, or (no path) from the project's own copy; bridge mode supports only the latter |
mesh_export |
{path, format?} |
The mesh as obj, glb, usda, usdc or usdz below the write root (obj: its .mtl and textures, usda / usdc: textures next to it) → {path, textures?} |
bake_mesh_add |
{path, cage?} |
Add a high-definition mesh for baking, or set the cage → {name, meshes} |
resource_import |
{path} |
Image resource (png, jpg, tga, tif, bmp, webp, exr, hdr) → its name, for layer.add.image / tool.stencil, or as a mesh map instead of baking it: textureSet.meshMap.set {ts?, map, image} (textureSet.meshMap.clear {ts?, map} empties the slot) |
material_save |
{path, layer?, name?} |
A layer or folder as a .tcmat smart material below the write root |
material_load |
{path} |
Add a .tcmat smart material, a MaterialCraft .mcmat material or a .mcraft package above the selection |
textures_export |
{dir, preset?, ts?, format?, padding?, size?} |
Writes files below the write root |
bake |
{ts?, resolution?, maps?, cpu?} |
Mesh maps for generators and smart materials; rays on the GPU when there is one (cpu: true: on the CPU). Per-map ray settings (ao, thickness, bentNormal: rays, maxDistance, spread, distribution; AO ignoreBackface, selfOcclusion) are bake.settings parameters |
paint_stroke |
{points, viewport?, color?, roughness?, metallic?, height?, size?, hardness?} |
Paint tool + brush material + tool.stroke; points in viewport pixels |
viewport_render |
{view?: "3d" | "2d", width?, height?, yaw?, pitch?, samples?, bounces?, cpu?} |
Headless: PNG image (≤ 2048 px a side); yaw/pitch in degrees for this render only; samples (1–4096) path traces the 3D view instead, on the GPU when it has compute shaders, else (or with cpu: true) on the CPU (no GPU needed) |
viewport_screenshot |
{view?} |
Bridge: the app's 3D or 2D view |
ui_screenshot |
Bridge: the whole window (downscaled to 2048 px) | |
ui_inspect |
Bridge: UI state | |
ui_menu_invoke |
{id} |
Bridge: a menu click |
Replies are capped: 8 MiB of JSON, 5 MiB per PNG, 256 batch steps. Stroke points and render
sizes share one space: the default render size is the session viewport (1280×800), so
paint_stroke points can be read straight off a viewport_render image.
A typical agent run: project_new {"mesh": "sphere"} → command_run layer.add.fill {"color": "#336699", "roughness": 0.2} → bake {"resolution": 512} → command_run mask.add.black → command_run effect.add.generator {"generator": "edgeWear"} →
session_inspect → viewport_render → project_save {"path": "ball.tcraft"} →
textures_export {"dir": "textures", "preset": "glTF PBR Metal Roughness"}.
Claude Code
claude mcp add texturecraft -- texturecraft-cli mcp --read-root . --write-root .
# bridged to the running app:
claude mcp add texturecraft-app -- texturecraft-cli mcp --bridge 7878
or a project .mcp.json:
{
"mcpServers": {
"texturecraft": { "command": "texturecraft-cli", "args": ["mcp", "--read-root", ".", "--write-root", "."] }
}
}
Claude Desktop
In claude_desktop_config.json (Settings › Developer › Edit Config), with absolute paths:
{
"mcpServers": {
"texturecraft": {
"command": "C:\\Program Files\\TextureCraft\\texturecraft-cli.exe",
"args": ["mcp", "--read-root", "C:\\Users\\me\\Texturing", "--write-root", "C:\\Users\\me\\Texturing"]
}
}
}
Headless JSON-lines server
texturecraft-cli serve keeps one headless session open and answers the control channel's
envelope on stdin/stdout (no authentication: the caller started the process). With
--port <port> it listens on 127.0.0.1:<port> instead, with the same auth handshake and
token rules as the app (a generated token goes to <temp>/texturecraft-control-<port>.token).
File access follows --read-root / --write-root as for MCP.
| Method | Params |
|---|---|
ping, methods, session.inspect |
|
command.run |
{id, params?} |
command.list |
{filter?} |
command.batch |
{steps: [{id, params?}]} |
project.open |
{path} |
project.save |
{path?} |
mesh.import |
{path, resolution?, name?} |
mesh.reimport |
{path?, preserveStrokes?} |
mesh.export |
{path, format?} |
bake.mesh.add |
{path, cage?} |
resource.import |
{path} |
material.save |
{path, layer?, name?} |
material.load |
{path} |
textures.export |
{dir, preset?, ts?, format?, padding?, size?, bitDepth?, dilation?, normalFormat?} |
render |
{view?, width?, height?, yaw?, pitch?} → {width, height, png} |
printf '%s\n' '{"id":1,"method":"command.run","params":{"id":"project.new","params":{"mesh":"torus"}}}' \
'{"id":2,"method":"render","params":{"width":512,"height":512}}' | texturecraft-cli serve
MaterialCraft materials
A .mcmat portable material (MaterialCraft's format, read here by formats::mcmat; the manifest is
documented in MaterialCraft's docs/control-protocol.md § Portable materials) becomes a fill layer
whose channel sources are its cooked maps and whose link (shown by project.inspect) holds the
graph's exposed inputs, presets and image inputs. The apps share the format and the command line,
not code.
| Command | Params |
|---|---|
material.load |
{path, above?, inside?, index?}: .tcmat, .mcmat or .mcraft (cooked first) |
material.link.set |
{layer?, id, value, cook?: true}: a number, [..], a bool, "#rrggbb" or a typed {"t", "v"}; re-cooks unless cook: false |
material.link.preset |
{layer?, name, cook?: true} |
material.link.cook |
{layer?}: re-cook at the texture set's resolution with its baked mesh maps (after a bake or a resize) |
material.link.saveSource |
{layer?, path}: the .mcraft package, to open in MaterialCraft |
Re-cooks run materialcraft-cli run <package> file.exportMaterial {path, size, values, inputs}:
$MATERIALCRAFT_CLI, else materialcraft-cli next to the TextureCraft executable, else on PATH
(the app is $MATERIALCRAFT / materialcraft). Baked maps feed graph input nodes whose identifier
matches the map's shared id (ambientOcclusion, curvature, thickness, position,
worldNormal, normal, height, id, bentNormal, opacity, colorMap, worldDirection;
case and underscores ignored). Usages map to channels of the same id (baseColor, normal in
OpenGL convention, roughness, metallic, height, ambientOcclusion, emissive, opacity,
specular); missing channels join the texture set.
CLI
texturecraft-cli --version # TextureCraft 0.1.0 (abc1234 2026-10-14)
texturecraft-cli info model.glb # triangles, materials, UV report
texturecraft-cli info project.tcraft # texture sets, layers, baked maps
texturecraft-cli commands --filter mask
texturecraft-cli new builtin:material_ball ball.tcraft --resolution 1024 --sample # mesh maps baked at ≤ 1024 px
texturecraft-cli new model.glb model.tcraft --resolution 2048
texturecraft-cli run ball.tcraft layer.add.fill '{"color":"#c08040","metallic":1}' -o ball.tcraft
texturecraft-cli batch script.json
texturecraft-cli bake model.tcraft --resolution 2048 --maps ambientOcclusion,curvature,thickness
texturecraft-cli export model.tcraft out/ --preset "Unreal Engine (Packed)" --format png --size 2048
texturecraft-cli render ball.tcraft ball.png --size 1280x800 --view 3d --yaw 30 --pitch 15
texturecraft-cli render scene.tcraft hero.png --size 1200x900 --camera Hero --samples 2048 # a saved camera (camera.add), path traced
<input> is a .tcraft project, a mesh file or builtin:<id>. A batch script is a list of
steps or an object with an optional input and output (paths relative to the current directory):
{
"open": "builtin:sphere",
"steps": [
{"id": "layer.add.fill", "params": {"color": [0.6, 0.6, 0.62], "metallic": 1, "roughness": 0.3}},
{"id": "bake.run", "params": {"resolution": 512}},
{"id": "material.apply", "params": {"material": "iron_rusted"}}
],
"save": "rusty.tcraft"
}
Errors print texturecraft-cli <command>: error: … and exit 1; usage errors exit 2. The CLI is a
console program and never opens a window; render needs a GPU or software adapter.