16 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.
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 (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 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
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.