Skip to content

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/mcp

Cursor — 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

AreaTools
Advanced Studioadvanced_list_capabilities, list_workflows, get_workflow, create_workflow, update_workflow, delete_workflow, upload_workflow_image, upload_workflow_media
Sharingexport_workflow, preview_workflow_import, import_workflow
Runs and versionsrun_workflow, run_batch, get_run, list_runs, get_run_status, resume_run, list_versions, get_version, restore_version
Triggerslist_triggers, create_trigger, update_trigger, delete_trigger, run_trigger
Characterslist_characters, get_character, create_character, rename_character, add_character_photo, build_angle_profile, delete_character
Poseslist_poses, suggest_poses, add_pose, delete_pose
Historylist_generations, get_generation, delete_generation, estimate_generation_cost
Metricsget_metrics
Settingsget_settings (read-only; reports which credentials are configured, never their values)
Engineslist_engines

Things worth knowing:

  • Files go in by path. Upload tools take an absolute filePath on this machine. Every result that mentions a stored file also lists it under files, with its absolute URL and its path on disk.
  • Runs don't block. run_workflow starts generating and returns a runId at once; poll get_run until its status is completed, partial or failed. Nodes run in dependency order, independent ones in parallel, and a node downstream of a failure is skipped with a clear error. Pass nodeIds to 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_workflow and update_workflow return warnings for 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 revision you last read as expectedRevision; 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, an llmText instruction or an imageTransform caption or instruction — "{{product}} on a {{surface}}, studio light";
  • set data.inputName on 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.

  • llmText writes text from an instruction and any connected text: captions, hooks, prompt variations, translations. It uses an engine whose text.supported is true: OpenAI, Gemini or the Codex CLI.
  • list fans 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 and 1. 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.
  • imageTransform crops (fit: "cover") or pads ("contain") an image to 9:16, 4:5, 1:1, 16:9 or 3:4, or keeps it at "original" size. It can also write a caption band from its overlayText or connected text. It runs locally and costs nothing. Set engine: "codex" and an instruction to 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.
  • output marks deliverables. get_run → deliverables lists what reached the run's output nodes, named after each node's label. Without output nodes, deliverables lists 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 }
  • schedule is { "type": "interval", "minutes": 5…10080 }, { "type": "daily", "time": "HH:MM" } (server time) or null. A trigger that came due while PoseForge was off runs once when it starts again.
  • webhook: true returns a webhookToken once. An outside service starts the trigger with POST /api/advanced-studio-projects/hooks/<triggerId>, sending Authorization: Bearer <token> and optionally a body of &#123; "inputs": &#123; … &#125; &#125;. Those inputs override the trigger's own. update_trigger with webhook: true replaces the token, and with false removes the webhook.
  • run_trigger fires a trigger now. A trigger whose previous run is still going is skipped, and the reason shows in lastError.
  • 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 lastError instead 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(&#123; versionId &#125;), 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(&#123; id, filePath &#125;) writes the workflow as one .poseforge.json file. It holds the nodes and connections, prompts and &#123;&#123;inputs&#125;&#125;, 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(&#123; filePath &#125;) checks a file without creating anything. Each generator in report.generators has a status:

StatusMeaning
readyRuns here as the author set it up.
needs-setupThe provider exists but is not configured, for example the file uses Higgsfield and only fal.ai is set up.
unknown-modelThe engine is here but does not offer that model.
unknown-engineThis version of PoseForge does not have that engine.
unsetNo model was chosen.

issues lists connections the model cannot take. alternatives lists the configured models that can take them.

import_workflow(&#123; filePath, name?, replacements? &#125;) creates the workflow. Every generator keeps the author's engine and model unless replacements switches it, for example &#123; "clip": &#123; "engine": "fal-video", "model": "<id>" &#125; &#125;. 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.

On this page