MCP server
Automate characters, poses, Advanced Studio workflows, history and metrics from any local agent.
PoseForge includes a Model Context Protocol server. Any agent on your machine — Claude Code, Cursor, an IDE assistant, or a local model behind an MCP-capable client — can manage characters and poses, build and run Advanced Studio workflows, and read history and metrics, without opening the UI.
The server is part of the app: whenever PoseForge is running, it is at
http://127.0.0.1:3000/mcp(use your PORT if you changed it). It speaks Streamable HTTP and is
stateless, so any compliant client works.
Connect a client
Claude Code
claude mcp add --transport http poseforge http://127.0.0.1:3000/mcpCursor — add to .cursor/mcp.json (or ~/.cursor/mcp.json):
{
"mcpServers": {
"poseforge": { "url": "http://127.0.0.1:3000/mcp" }
}
}Anything else — point the client's HTTP (Streamable HTTP) transport at
http://127.0.0.1:3000/mcp. Clients that only speak stdio can use a bridge
such as npx mcp-remote http://127.0.0.1:3000/mcp.
Tools
| Area | Tools |
|---|---|
| Advanced Studio | advanced_list_capabilities, list_workflows, get_workflow, create_workflow, update_workflow, delete_workflow, upload_workflow_image, upload_workflow_media |
| Sharing | export_workflow, preview_workflow_import, import_workflow |
| Runs and versions | run_workflow, run_batch, get_run, list_runs, get_run_status, resume_run, list_versions, get_version, restore_version |
| Triggers | list_triggers, create_trigger, update_trigger, delete_trigger, run_trigger |
| Characters | list_characters, get_character, create_character, rename_character, add_character_photo, build_angle_profile, delete_character |
| Poses | list_poses, suggest_poses, add_pose, delete_pose |
| History | list_generations, get_generation, delete_generation, estimate_generation_cost |
| Metrics | get_metrics |
| Settings | get_settings (read-only; reports which credentials are configured, never their values) |
| Engines | list_engines |
Things worth knowing:
- Files go in by path. Upload tools take an absolute
filePathon this machine. Every result that mentions a stored file also lists it underfiles, with its absolute URL and its path on disk. - Runs don't block.
run_workflowstarts generating and returns arunIdat once; pollget_rununtil its status iscompleted,partialorfailed. Nodes run in dependency order, independent ones in parallel, and a node downstream of a failure is skipped with a clear error. PassnodeIdsto run only some. - Every run is a record with the version it ran, its inputs, each node's outcome, its output files and its estimated cost — nothing is lost when a node's own result list (the last eight) moves on.
- Hand-written documents get feedback.
create_workflowandupdate_workflowreturnwarningsfor anything dropped or ignored: an unknown node type, an edge to a missing node, a misspelled handle, or an aspect ratio the engine doesn't support. - Saves are revision-checked, like the canvas autosave. Pass the
revisionyou last read asexpectedRevision; on a conflict, read again and reapply. - Workflows you create appear in Advanced Studio, so you can open, tweak and rerun them in the UI.
- Generations spend credits on whichever provider the node uses. Destructive tools are marked so clients ask before running them.
Templates, inputs and batches
A workflow becomes a reusable template once it declares inputs:
- write
{{name}}anywhere in a text or list node, anllmTextinstruction or animageTransformcaption or instruction —"{{product}} on a {{surface}}, studio light"; - set
data.inputNameon a text, list, image, video or audio input node; its current value is the default; - or bind a node setting with
data.inputBindings(see below).
get_workflow lists them under inputs. Fill them per run without changing
the saved workflow:
run_workflow { "id": "…", "inputs": { "product": "a ceramic mug", "surface": "oak table", "hero": { "characterId": "…" } } }run_batch runs one workflow once per row — up to 100 rows, e.g. every
product × pose — and returns a batchId; list_runs({ batchId }) follows
them. Every row is validated before any starts: an unknown or missing input
stops the batch before anything is spent.
Image inputs accept more than uploaded files: { "characterId": "…" } (that
character's current primary photo), { "poseId": "…" } (a pose library
image — including the many that live on Unsplash or Pexels), or any http(s)
image URL. Web images are downloaded once into the workflow's storage and
reused.
Building blocks for content
Besides prompts, inputs and generators, a workflow can use these nodes.
advanced_list_capabilities → nodeTypes lists every node type's inputs
and outputs.
llmTextwrites text from aninstructionand any connected text: captions, hooks, prompt variations, translations. It uses an engine whosetext.supportedis true: OpenAI, Gemini or the Codex CLI.listfans a workflow out. It makes one item per line of the connected text, or of its own text if nothing is connected, and drops bullets and1.numbering. Every running node after it runs once per item, up to 25. An LLM's "write 5 ideas, one per line" becomes five images.imageTransformcrops (fit: "cover") or pads ("contain") an image to9:16,4:5,1:1,16:9or3:4, or keeps it at"original"size. It can also write a caption band from itsoverlayTextor connected text. It runs locally and costs nothing. Setengine: "codex"and aninstructionto have the Codex CLI edit the image however you describe: remove a background, add a logo, grade the colours, lay out a title. Codex gets connected text as material, and the format is applied to its result. Codex works on a copy in a scratch folder and can use local tools such as Python, ImageMagick or its image generator.outputmarks deliverables.get_run→deliverableslists what reached the run's output nodes, named after each node's label. Without output nodes,deliverableslists every result.
Example: llmText ("5 hooks about {{topic}}") → list → imageGenerator →
imageTransform (9:16, with the list also wired to its caption) → output.
One run gives five captioned story images.
Fallbacks. A generator or llmText node can list up to three
fallbacks: [{ "engine": "…", "model": "…" }]. They are tried in order when
the node's own model is not set up or fails, and each result records the
engine and model that made it.
Setting inputs. data.inputBindings turns a setting into a run input,
for example { "aspectRatio": "format" } on a generator or
{ "preset": "format" } on a transform. get_workflow → inputs lists the
values each one accepts, and a run refuses anything else. One batch can then
make every format from a single workflow:
run_batch { "id": "…", "rows": [{ "format": "9:16" }, { "format": "1:1" }, { "format": "16:9" }] }Resuming runs
resume_run reruns the unfinished nodes of a failed or partial run with
the same version and inputs. Use it after a provider outage, after setting
up a provider, or after a restart. Finished nodes don't run again: what they
produced feeds the resumed nodes. Runs execute inside the PoseForge server,
so a run still going when the server stops is closed at the next start: it
becomes partial (finished nodes keep their results) or failed, and it
can be resumed.
Triggers: unattended runs
A trigger saves how to run a workflow, meaning its inputs and nodeIds,
plus when to run it:
create_trigger { "id": "…", "name": "Morning post", "inputs": { "topic": "kites" },
"schedule": { "type": "daily", "time": "08:30" }, "webhook": true }scheduleis{ "type": "interval", "minutes": 5…10080 },{ "type": "daily", "time": "HH:MM" }(server time) ornull. A trigger that came due while PoseForge was off runs once when it starts again.webhook: truereturns awebhookTokenonce. An outside service starts the trigger withPOST /api/advanced-studio-projects/hooks/<triggerId>, sendingAuthorization: Bearer <token>and optionally a body of{ "inputs": { … } }. Those inputs override the trigger's own.update_triggerwithwebhook: truereplaces the token, and withfalseremoves the webhook.run_triggerfires a trigger now. A trigger whose previous run is still going is skipped, and the reason shows inlastError.- Each trigger is checked against the workflow when you save it. If the
workflow later changes so a firing no longer fits, that firing records
lastErrorinstead of running.
Scheduled and webhook runs spend provider credits with nobody watching, and
spending limits in Settings still apply. PoseForge listens on localhost, so
an outside service can only reach a webhook through a tunnel. If you set one
up, forward only the /api/advanced-studio-projects/hooks/ path, not the
whole app.
Versions
A version is each distinct set of nodes, settings and connections that has
run; moving nodes or new results never make one. Runs record their version,
so you can always answer "which version made this?":
list_versions (with currentVersionId, the version the canvas matches),
list_runs({ versionId }), get_version, and restore_version to put an
older recipe back on the canvas — current results stay. Each version reports
costUsd, the total of its completed generations' recorded estimates;
engines billed through a plan (Codex, fal) record no price, so
when pricedGenerationCount is below generationCount the total is partial.
In the app, the Versions menu in the Advanced Studio toolbar lists the
same versions with their cost.
Sharing workflows
export_workflow({ id, filePath }) writes the workflow as one
.poseforge.json file. It holds the nodes and connections, prompts and
{{inputs}}, the engine and model of each generator, and the input images and
clips embedded as base64. Character and pose inputs embed the photo they show
today. Outputs, settings, keys and history are never included.
preview_workflow_import({ filePath }) checks a file without creating
anything. Each generator in report.generators has a status:
| Status | Meaning |
|---|---|
ready | Runs here as the author set it up. |
needs-setup | The provider exists but is not configured, for example the file uses Higgsfield and only fal.ai is set up. |
unknown-model | The engine is here but does not offer that model. |
unknown-engine | This version of PoseForge does not have that engine. |
unset | No model was chosen. |
issues lists connections the model cannot take. alternatives lists the
configured models that can take them.
import_workflow({ filePath, name?, replacements? }) creates the workflow.
Every generator keeps the author's engine and model unless replacements
switches it, for example { "clip": { "engine": "fal-video", "model": "<id>" } }.
On a switch, each setting the new model supports is kept and the rest take
its defaults. Imported files are untrusted: they may only use their own
embedded files or web URLs, never paths on your machine, and every embedded
file is decoded and checked before it is stored.
Example: one photo to a short clip
Ask your agent:
Create a character called Maya from ~/photos/maya.jpg. Build an Advanced Studio workflow where her photo and a prompt make a 16:9 editorial portrait, and that portrait is the start frame of a 5-second video. Run it and give me the output files.
The agent will typically call advanced_list_capabilities to pick ready
engines, create_character, then create_workflow with a document like this:
{
"nodes": [
{ "id": "prompt", "type": "text", "position": { "x": 0, "y": 0 },
"data": { "text": "Editorial portrait of the woman in the reference, golden hour" } },
{ "id": "maya", "type": "imageInput", "position": { "x": 0, "y": 300 },
"data": { "imageUrl": "<primaryPhotoUrl from create_character>" } },
{ "id": "portrait", "type": "imageGenerator", "position": { "x": 500, "y": 0 },
"data": { "engine": "openai", "aspectRatio": "16:9" } },
{ "id": "clip", "type": "videoGenerator", "position": { "x": 1000, "y": 0 },
"data": { "engine": "fal-video", "model": "<a model id from advanced_list_capabilities>", "duration": 5 } }
],
"edges": [
{ "id": "e1", "source": "prompt", "sourceHandle": "text", "target": "portrait", "targetHandle": "prompt", "dataType": "text" },
{ "id": "e2", "source": "maya", "sourceHandle": "image", "target": "portrait", "targetHandle": "image", "dataType": "image" },
{ "id": "e3", "source": "prompt", "sourceHandle": "text", "target": "clip", "targetHandle": "prompt", "dataType": "text" },
{ "id": "e4", "source": "portrait", "sourceHandle": "image", "target": "clip", "targetHandle": "startFrame", "dataType": "image" }
]
}It then calls run_workflow and polls get_run. The run's outputs, and
the files list, point at the image and the MP4 on disk.
Generator target handles: prompt (text), image (reference images for an
image generator), startFrame, endFrame and reference (images for a video
generator), referenceVideo and referenceAudio.
Security
The endpoint has no API key because it only listens on loopback. To stop a
web page in your browser from reaching it (DNS rebinding), PoseForge rejects
requests whose Host isn't localhost/127.0.0.1/[::1] and requests from
any non-local browser Origin.
If you start PoseForge with HOST set to anything other than a loopback
address, /mcp is off unless you also set POSEFORGE_MCP_TOKEN; every
request must then send Authorization: Bearer <token>. The rest of the app
still has no authentication — see SECURITY.md.
Provider API keys are never exposed: get_settings drops even the masked
previews, and there is no tool to change settings.
Out of scope
The guided Studio (character → pose transform) isn't exposed. Build the same thing as an Advanced Studio workflow with a character image feeding an image generator. Presets, recipes and ID photos aren't exposed yet either.
For contributors: new features must ship with MCP tools
Every API route is either an MCP tool or a deliberate exclusion.
tests/mcpCoverage.test.js walks every router in server.js's API_ROUTES
and fails, naming the route, when one is neither a tool's route in
lib/mcp/tools/ nor listed in lib/mcp/exclusions.js with a reason. Most
tools are a short declaration:
{
name: "add_pose",
route: "POST /api/pose-references",
description: "Add a local photo to the pose library as a custom pose reference.",
input: { filePath: z.string(), title: z.string().optional() },
files: { posePhoto: "filePath" }, // multipart field ← argument holding a local path
}Tools call PoseForge's own HTTP API over loopback, so every rule the route enforces applies to agents too, and nothing is implemented twice.