rig-worker is an MCP server in a single Cloudflare Worker. An agent describes a character as simple shapes, gets back one closed mesh, places bones, binds them with automatic weights, keys a walk, and checks every step with rendered pictures and numbers. It needs no Blender, no GPU and no install, yet everything it makes opens in Blender as it was built.
✓ Blender compatibleMesh, colours, bones, weights and every clip import into Blender 4.5 with default settings, and come back the same. Checked by a round-trip test.
The public demo needs no token. It is rate limited and keeps your files (separate per client IP) for a day. Point any MCP client that speaks Streamable HTTP at the /mcp URL.
claude mcp add --transport http rig-worker \
https://rig-worker-demo.epinnock.workers.dev/mcp
Then ask: "Use rig-worker to model a small toy robot, rig it and make it wave. Show me the pictures."
{
"mcpServers": {
"rig-worker": {
"type": "http",
"url": "https://rig-worker-demo.epinnock.workers.dev/mcp"
}
}
}
Your own deployment adds "headers": {"Authorization": "Bearer <RIG_TOKEN>"}.
git clone https://github.com/epinnock/rig-worker && cd rig-worker
node examples/pip/run.mjs # model -> bind -> walk -> qa on the public demo, pictures in out/pip
Every picture on this page came out of the tools, the same pictures an agent sees after each call. The calls below are the ones in examples/pip; on the public demo the whole run takes about 23 seconds.
A model is a list of signed-distance shapes (spheres, capsules, tapered tubes, lathes, boxes...) combined in order with smooth unions, cuts and paint. The Worker meshes it into one closed, watertight mesh with vertex colours, ready to rig, and returns a preview in the same call.
{ "name": "model", "arguments": { "out": "pip.glb", "preview": {}, "model": { "shapes": [
{ "name": "head", "type": "sphere", "at": [0, 0.84, 0], "radius": 0.2, "blend": 0.04, "color": "#f1c6a6" },
{ "name": "arm", "type": "tube", "mirror": "x", "points": [[0.12, 0.64, 0, 0.052], [0.22, 0.52, 0.01, 0.046], [0.27, 0.41, 0.03, 0.043]], "blend": 0.03 },
{ "name": "eye", "type": "ellipsoid", "mirror": "x", "radii": [0.026, 0.032, 0.014], ... },
...
] } } }


preview overlays a proposed skeleton on the mesh, so the agent can see a knee sitting outside the leg before it spends a bind. Bones are plain JSON: a head, a tail and a parent, in metres.
{ "name": "UpperLeg.L", "parent": "Hips",
"head": [0.075, 0.36, 0], "tail": [0.08, 0.2, 0.005] }

bind solves bone heat (Baran & Popović 2007, the method behind Blender's Automatic Weights) on a closed voxel proxy, so it also works on broken scan or AI-generated meshes. A weights preview shows which bone owns each vertex.
{ "name": "bind", "arguments": { "file": "pip.glb", "out": "pip_rig.glb", "skeleton": { "bones": [...] } } }
// -> { createdSkin: true, vertices: 56790, bones: 14, bonesOwningNoVertex: [] }

animate writes keyframes as Euler degrees per bone. qa samples the clip and reports frames that sink below the floor, stretched or squashed edges, and hotspots with a ready-made region to repair with paint_weights.
{ "name": "qa", "arguments": { "file": "pip_rig.glb", "animation": "walk", "frames": 24 } }
// -> summary: { framesSinkingBelowGround: 0, maxSink: 0.002, worstEdgeRatio: 4.88 }
// hotspots: [{ dominantBone: "LowerLeg.L", restBox, surfacePoint, points, ... }]

This calls the model tool on the demo Worker straight from your browser. Edit the JSON (change a colour, move the pompom, add a shape) and build. The demo allows 6 builds a minute.
Files are GLBs in the Worker's R2 bucket, named with letters, digits, . _ -. Tools that change a file take out to save under a new name (default: overwrite). The full JSON schemas come from tools/list.
| Tool | What it does | Key arguments |
|---|---|---|
model | Shapes → one closed mesh with vertex colours; stores the model JSON in the file so later calls send only a patch. Optional preview in the same call. | out, model {shapes, res} or from + patch, res, preview |
get_model | The model JSON inside a file made by model. | file |
inspect | Bounds, counts, horizontal slices with separate blobs (legs and arms show up as clusters), current skeleton and animations. | file, slices |
preview | PNG views: shaded, weights (colour per dominant bone) or one bone's weight; skeleton overlay or a proposed skeleton; a pose; animation frames; zoom on a joint. | file, mode, bone, views, elev, skeleton, pose, frames, time, focus, res |
bind | Builds the skeleton and skin (or re-weights an existing skin) with bone-heat weights. | file, skeleton {bones}, options {voxel, lapScale} |
get_skeleton | The skin's skeleton as bind JSON, to edit and bind again. | file |
paint_weights | Set, add or scale one bone's weight in a sphere or box; other bones keep their proportions; top 4, normalised. | bone, region, op, value, falloff |
animate | Writes or replaces a keyframed clip: rotations per bone, offsets for the root. | name, duration, loop, interpolation, tracks [{bone, keys [{t, rot, move}]}] |
qa | Samples a clip or pose: frames below the rest-pose floor, edges past a length ratio, hotspots with restBox, surfacePoint and flagged points. | animation or pose, frames, stretch, sinkTolerance |
export | A signed download link (no header needed), plus a link to the file in the 3D viewer. | file, expires_minutes |
view | Shows a file to the user in the interactive 3D viewer: inline in hosts with MCP Apps (Claude and others), and as a signed link for any browser. | file, expires_minutes |
list_files | Stored files with sizes. | |
delete_file | Delete a stored file. | file |
import_url | Fetch a GLB from a URL into storage (self-hosted only). | url, name |
y = 0 facing +Z.[x, y, z], applied X, then Y, then Z, about the world axes through the joint for bones bind made (+X swings a leg back, +Y turns toward the character's left, +Z raises the left arm), and about the joint's own rest axes for rigs made elsewhere. The bones themselves are stored pointing along the limb, as Blender expects.Ask the agent to show you a model and it calls view. In Claude, and in other hosts that support MCP Apps, the viewer opens right in the chat. Everywhere else the tool returns a signed link that opens the same viewer in a browser for an hour (up to a week on a self-hosted Worker). Orbit and zoom, play or scrub every clip or go to the bind pose, colour the mesh by skin weights (by region, or one bone as a heat map), and turn on the wireframe, skeleton and bounds. The panel lists triangles, bones, size and every clip.
It is live. Every open copy of a file, inline or from a link, reloads within a few seconds when the agent saves that file again under the same name. You keep your camera, clip, time and shading. An old link shows the latest version until it expires.
| Where | How |
|---|---|
| Claude, MCP Apps hosts | view carries _meta.ui.resourceUri = ui://rig-worker/viewer.html; the host renders that resource and passes it the tool result, a signed GLB link on this Worker. |
| Any browser | The view link, /view/<file>?exp&sig, which export also returns. No token needed until the link expires. The page checks the file's ETag every 2 s (every 15 s after 10 quiet minutes, paused in a hidden tab) and reloads in place when it changes. |
| Your own page | viewer/?src=<url of a .glb> on this site (the server needs CORS), or copy src/viewer.html: one file, with three.js from jsDelivr. |
Each shape is combined, in list order, with everything before it. Because the result is one signed distance field, the mesh is always closed and in one piece when the parts overlap, which is exactly what rigging wants.
| Type | Parameters (local frame) | Good for |
|---|---|---|
sphere | radius | heads, hands, eyes |
ellipsoid | radii [x, y, z] | bodies, ears, flattened eyes |
box | size [x, y, z], round | boots, backpacks, robots |
cylinder | radius, height, round (axis Y) | brims, wheels, posts |
cone | radius, radiusTop, height (axis Y) | skirts, hats, noses |
torus | radius, thickness (ring in XZ) | collars, hat folds, mouths (as a cut) |
capsule | from, to, radius, radiusTo | torsos, simple limbs |
tube | points [[x, y, z, r], ...] | arms, legs, tails, hair strands |
lathe | profile [[r, y], ...] revolved round Y | bottles, vases, hats |
group | shapes [...], combined as one shape | a cap = sphere minus a box, without cutting the head |
name: unique, used by patchesop: union (default), subtract, intersect, paint (recolour only)blend: metres of smooth fillet with what came before (0.01 to 0.05 for body joins)mirror: "x": adds the copy at −x; model the left side at +xat, rot: move and turn (degrees, X then Y then Z)color: #rrggbb; a shape without one takes the colour under itResolution: res defaults to the model's size / 160 (a 1.7 m figure gets about 1 cm and 25k vertices). Smaller is finer and slower, capped at 8M grid points.
The model JSON lives inside the GLB, so refinements are small:
{ "out": "pip2.glb", "from": "pip.glb", "patch": [
{ "name": "beanie", "color": "#cc3344" },
{ "name": "scarf", "type": "torus", "before": "head",
"at": [0, 0.66, 0], "radius": 0.12, "thickness": 0.03 },
{ "name": "pocket", "remove": true }
] }
A patch merges fields (null removes one), adds new names at the end, into a group or before a shape. The result tells you per-shape vertex counts and warns about shapes with no visible effect and separate pieces.
inspect → preview with a proposed skeleton → bind → preview mode: "weights" → animate → preview frames + qa → paint_weights fixes → export. The same loop works on a GLB you upload: bind voxelizes the mesh into a closed proxy first, so hollow or many-piece meshes still get clean weights.
{ "bones": [
{ "name": "Hips", "parent": null, "head": [0, 0.38, 0], "tail": [0, 0.5, 0] },
{ "name": "Spine", "parent": "Hips", "head": [0, 0.5, 0], "tail": [0, 0.66, 0] },
{ "name": "Nose", "parent": "Head", "head": [...], "tail": [...],
"falloff": { "radius": 0.04, "power": 2, "at": "tail" } }
] }
falloff gives tiny bones (a nose, an ear tip) distance weights instead of heat. Bind again with an edited skeleton to move bones; bone names must match the existing skin.
{ "bone": "Spine", "op": "set", "value": 1, "falloff": "smooth",
"region": { "sphere": { "centre": [0, 0.52, -0.2], "radius": 0.11 } } }
Prefer a smooth sphere to a hard box: a box with set 1 leaves a seam where it ends (on Pip it raised the worst edge ratio from 5 to 20).
Self-hosting removes the demo limits and keeps your files private behind a token. You need a Cloudflare account on the Workers Paid plan, because a bind on a large mesh can use up to a minute of CPU (the config allows 5 minutes).
Cloudflare forks the repo, creates the R2 bucket and asks for RIG_TOKEN: any long random string (openssl rand -hex 32). Clients send it as Authorization: Bearer …; it also signs download links.
git clone https://github.com/epinnock/rig-worker && cd rig-worker
npm install
npx wrangler deploy # creates the R2 bucket on first deploy
openssl rand -hex 32 | npx wrangler secret put RIG_TOKEN
claude mcp add --transport http rig-worker https://rig-worker.<you>.workers.dev/mcp \
--header "Authorization: Bearer <RIG_TOKEN>"
cp .dev.vars.example .dev.vars # put a token in it
npm run dev # wrangler dev on :8799, local R2
npm test # model -> bind -> animate -> qa in Node, no network, ~15 s
RIG_TOKEN=... node examples/pip/run.mjs http://127.0.0.1:8799
| Route | What it does |
|---|---|
POST /mcp | MCP Streamable HTTP (stateless JSON responses, protocol 2025-06-18) |
PUT /files/<name> | Upload a GLB (body = bytes, max 64 MB; 8 MB on the demo) |
GET /files, GET|DELETE /files/<name> | List, download, delete |
GET/HEAD /dl/<name>?exp&sig | Signed download from export or view, no header. Sends an ETag with no-cache and answers If-None-Match with 304, so a link always serves the latest save |
GET /view/<name>?exp&sig | The same file in the 3D viewer, no header |
POST /bind | Multipart glb + skeleton (+ opts) → rigged GLB, stats in x-rig-stats (self-hosted only) |
The demo is the same code with DEMO=1 (npx wrangler deploy --env demo): no token, so everything is capped.
| Requests | 30 a minute per client IP; model and bind 6 a minute per client and 12 a minute overall |
| Files | a separate space per client IP (an HMAC of it, not the IP itself), at most 25 files, deleted after a day; uploads up to 8 MB |
| Meshes | up to 150k vertices; model up to 100 shapes and 4M grid points; bind voxel ≥ 0.008 |
| Off | import_url and POST /bind; links expire within 24 h; 60 s of CPU per call |
Every GLB rig-worker writes imports into Blender (tested with 4.5) with the default glTF settings and no clean-up, and Blender's own export comes back the same. A test in the repo checks this end to end: Blender builds a textured mesh and a rig of its own, rig-worker models, binds, paints and animates, Blender imports each result and compares it with rig-worker's numbers, then re-exports it for rig-worker to read back.
| Checked in Blender 4.5 | Result |
|---|---|
| Shape at rest | same vertices in the same order, within 0.0003 mm |
| Vertex colours | a colour attribute wired into the material, within 8-bit rounding |
| UVs and image textures on meshes you bring | kept through bind and animate |
| Bones | same names and parents; each bone points from its head to its tail, so arms and legs point along the limbs, as in a rig made in Blender |
| Weights | one vertex group per bone, same values |
| Clips | one action per clip; the deformed mesh matches within 0.05 mm at eight times per clip, for linear, stepped and root-motion clips, and after switching from another clip |
| Rigs made in Blender | re-weighted in place with their own bone axes, rolls and actions kept |
| Blender's re-export | reads back into rig-worker with the same shape, weights and motion, and binds and animates again |
Run it with BLENDER=/path/to/blender npm run test:blender (about 30 s). Two things in the files exist for Blender's sake: animate keys every bone in every clip (bones a clip leaves alone hold their rest pose, because Blender otherwise keeps them where the last clip put them), and it adds keys every 20° on big turns, because Blender blends quaternion keys linearly where glTF asks for a slerp.
src/sdf.js): shapes compile to signed distance functions with bounding-sphere pruning; the field is sampled on a sparse grid (one sample per 4³ block away from the surface), meshed with surface nets, each vertex projected onto the true surface, coloured by the shape that owns it.src/heat.js): voxelize, close small gaps and flood-fill to a solid; extract and smooth a closed proxy surface; solve (L + A·H) w = A·H·p per bone with a cotangent Laplacian and visibility-tested nearest bones; copy weights back by nearest point; keep 4 influences.src/render.js): a small software rasterizer writes PNG tiles with skeleton overlays, so the agent can look at its own work.paint_weights (use out to keep versions).qa flags edges by length ratio and does not yet say stretched or squashed; tiny surface-net edges near creases can show high ratios.model makes vertex colours only, no UVs or textures (meshes you bring keep theirs); linear blend skinning with 4 influences.