nscr-houdini-mcp
Provides tools for working with SideFX Houdini, including direct Python access, managing multiple Houdini sessions (GUI and headless), building scenes, and organizing renders, caches, and captures in managed folders.
Click on "Deploy Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@nscr-houdini-mcpRender the current scene in the GUI session and compare against the reference."
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
nscr-houdini-mcp
An MCP server and a small set of agent skills for SideFX Houdini 22.
Status: early. Nothing here is usable yet.
Goals
A small tool set with direct Python access to Houdini, so the context cost stays low.
Works with any MCP client. No client-specific rules.
One setup talks to many Houdini sessions at once, both open GUI sessions and headless workers.
Agents check their work against a reference image before they call it done.
Renders, caches and captures go to managed folders next to the scene file.
Plain
SKILL.mdskills that help an agent build scenes a person can read, change and reuse. You can edit them to fit how you work.Runs on macOS, Windows and Linux.
Related MCP server: Houdini MCP Server
Development
Python 3.11 or newer. The server process never imports hou.
Setup, with uv:
uv venv
uv pip install -e ".[dev]"Or with the standard library (.venv\Scripts\activate on Windows):
python3 -m venv .venv && . .venv/bin/activate
pip install -e ".[dev]"Checks, the same ones CI runs:
ruff check . && ruff format --check .
pytest -q
python scripts/lint_client_names.py # shipped text must not name any client or vendor
python scripts/tools_list_cost.py # token cost of the tools/list payloadscripts/tools_list_cost.py serialises the tool list the way a client receives
it and counts its tokens, in total and per tool, against a budget of 4,000.
With the dev extras installed it counts with tiktoken and the o200k_base
encoding (--encoding names another), read from tiktoken's cache, or fetched
once with a ten second limit when it is not there. Without the package, or
with the encoding neither cached nor fetched, it falls back to bytes divided
by four, rounded up, and says which of the two happened. tiktoken is only ever a dev
dependency. Either count is a budget signal to watch across commits, since a
client on another encoding pays a somewhat different number. Pass
--max-tokens N to make it fail over a budget.
Git hooks:
python scripts/install_hooks.pyThis sets core.hooksPath to the tracked hooks/ directory. The hooks check
staged content and the commit message against a private term list at
.context/leak-terms.txt, which is ignored by git and kept only on your own
machine. They fail closed: with no list, every commit is refused.
Run the server on stdio:
nscr-houdini-mcpEleven tools so far. hou_ping says which session a call reaches and that it
answers. hou_sessions lists every session with its state (live, busy,
unresponsive, crashed or gone) and starts and stops workers under the
pool's rules; it never closes a Houdini with a user interface. hou_scene
reads the scene, opens a file and reports what it could not resolve as data,
saves in place, and saves the next <name>_v### without writing over
anything. A scene open in a user interface with unsaved changes is not
replaced unless the call says to throw them away. info says whether the
scene has unsaved changes and where that answer came from: Houdini itself in
a session with a user interface, and in a worker, which answers yes whatever
it holds, the bridge's own mark. That mark is clean after a save or a load,
dirty after any call that changes the scene or a merge, and unknown at start
or after code that saved or loaded by itself, when it cannot tell.
Opening a scene runs the code that scene file carries, as Houdini always
does, so only open files you trust.
hou_inspect reads nodes, networks and parameters. tree lists what is
under a path, node reads one node or a batch of up to fifty, parms reads
parameter tables, find searches by a glob on the name or path and by type,
and selection lists what is selected in a session with a user interface.
Rows come sorted by path, in every mode. A read with more rows than limit
(200 unless you say, up to 2000) hands back next_page; send it back as
page, with the same arguments, to carry on after the last path. A tree or a
search stops walking once its page is full, so total comes only with a
first page that holds everything. If the scene changed in between, the page
still comes back and says scene_changed. A batch never fails for one
missing path: that entry carries its own error, with the closest paths that
are there. A tree's network boxes and sticky notes come whole on its first
page, up to 500 of each, with boxes_truncated or notes_truncated past
that. A multiparm row carries up to 200 instances and says how many there
are; name the multiparm itself to page through all of them.
A read cooks nothing unless it passes evaluate. Without it, a value that
could only be had by cooking is left out and not_cooked says why: a number
a channel operator's export may drive, a parameter with several keyframes, a
Python expression, a backtick, a variable such as $NPT that only means
something inside a cook, or an expression that reads the scene, such as one
that counts another node's points, directly or through ch(). A node's
errors are the ones the last cook left, marked not_cooked when there has
been none and stale with the reason when the node has changed since. With
evaluate the read may cook, under the same wait_s and timeout_s as any
other call, and one that runs out of time is asked to stop between rows; a
render node or a task network is never cooked by a read.
Every tool that reads takes the same three detail levels:
summary, the default: who each item is and how many of things it has, one compact row per item.standard: what a person sees without digging, on the node and in the parameter pane: the parameters that differ from their defaults, the wires by input label, the flags, error text and comments.full: everything else: parameters at their defaults, expressions with their text and values, code, spare parameter templates, cook times and user data.
include brings single items into a lower level: wires, flags, errors,
expressions, code, notes or cook_time.
hou_python runs Python inside a session with the whole hou API. What the
code leaves in a variable named result comes back, turned into JSON on the
session's own thread the way every answer is: a node or a parameter as its
path, a vector or an array as a list, a numpy scalar as its number, up to the
usual caps. A collection is read only as far as its cap. Variables stay
between calls in a namespace, one dict per session and name, seeded with
hou and mcp and nothing else. A call that names none uses this server's
own, c_ and an id drawn when the server starts; shared is the name to use
when agents should share. A namespace goes when a call passes reset, when
the session ends, or when nobody has used it for an hour (a c_ default) or
a day (any other name). A session keeps at most 32, dropping the least
recently used, and its health says how many there are and roughly how big.
Separate namespaces keep variables apart, never the scene: every one of them
works on the same node graph.
What the code prints, on either stream, comes back as stdout_tail. Only
what the call's own thread prints is taken, and whatever the code does to
sys.stdout or sys.stderr is undone when the call ends. max_chars
(12,000 unless you say) is the budget for the result and the printed text
together; what does not fit is counted in elided_chars, and what the
session kept is written to the spill folder, named in spill_path. The
session keeps the last 512,000 characters of output and the result as it was
encoded, which is bounded too, so the spill holds that much and no more. Text
UTF-8 cannot carry, such as a lone surrogate in a file name, comes back as its
escape, and lossy with cut says where anything was changed or cut.
An exception in the code is not a failed call: the result carries error
with the type, the message and the last twenty lines of the traceback, places
on disk taken out, and the client sees it marked as an error. Code that will
not compile is the same, with a syntax error's line and offset. The namespace
and the scene are left as the code left them, on purpose: what the code did
before it raised stays as one undo entry, for a person to keep or take back.
Every call counts as a change. It runs in one undo group, named undo_label
or hou_python and the operation id, takes a receipt under that id and
follows the same busy and retry rules as any other change. Because the call
is inside its own undo group, hou.undos.performUndo() in the code raises;
taking a whole call back is for a person in a session with a user interface.
The receipt is bound to the arguments the caller sent, so a lost reply sent
again with the same operation id is answered from it, even by a server
started since and even when the call named no namespace, and the answer names
the namespace the code ran in.
Every call is also a job, with a job_id made from its operation id, so a
reply that never arrived can still be followed. background says how long
the call itself waits. auto, the default, waits up to inline_wait_s from
config.toml (ten seconds unless you say, from 1 to 50, and never longer
than timeout_s): code that finishes in that time answers as usual with its
state, and slower code answers with the job to follow at that moment and
carries on. true answers with the job as soon as the session has taken the
call. false waits up to timeout_s (a minute unless you say; anything above
python_timeout_cap_s, at most an hour, is lowered to it) and then answers
TIMEOUT with still_running and the job_id. The code is never stopped by
any of these, and the same operation id fetches its answer once it ends, also
when sent while the code is still running.
mcp in every namespace has four things, made afresh for each call and
answering only on that call's thread while it runs. mcp.output_path(kind, name, ext) hands out a managed path for this session and scene from the
output table below, for render, flipbook, comp, cache, usd, hip,
capture, compare, reference or check; never a spill or a job,
which are the server's own. The run it records names the call's job, so
hou_compare can find an image the job wrote by its job_id.
mcp.freeze_parm(parm, path) puts a path the call
was handed on an output parameter, given as a hou.Parm or its path, for as
long as the call runs; when the call ends, however it ends, the parameter
gets back what it held before, its value or its expression, and the answer
says so in restored_parms.
mcp.progress(done, total, message) leaves a note,
finite numbers only, that health and hou_ping show while the call runs.
The same note is written to the call's job, at most once a second.
mcp.cancelled() says whether the call should stop, for a long loop to look
at between pieces of work. It turns true when hou_jobs cancels the job or
the session is going down. Code that stops once it has seen a cancel ends
cancelled; code that never looks runs to the end and ends done, with the
request still on the job. Code that stops because its session is going down
ends lost, as it would had the session gone first.
hou_jobs follows long running work by id. status, the default, reads one
job: its state (queued, running, done, failed, cancelled or
lost), progress, outputs, error, when it started and ended, the scene epoch
it ran against and whether a cancel was asked for. With wait_s (up to 50)
the call is held until the state or the progress changes, and says so with
changed, so there is no need to poll with sleeps. For a Python job that has
ended, outputs is the answer the call would have given, fitted to
max_chars, and what does not fit is written to one spill file per job.
cancel writes the
request where the session reads it within two seconds and also asks the
session directly, and says whether that direct request got there. In a
session with a user interface a cancel is best effort: the code runs on
Houdini's main thread, and only code that looks at mcp.cancelled() stops.
list gives jobs newest first, by session and state, with next_page.
Jobs live in the coordination store, so any server can answer for any job,
including one started after the call that began it. A job's row is written
before its code may run, and the call is refused with STORE_UNAVAILABLE
when it cannot be; an operation id whose job is still kept cannot start
another (JOB_ID_TAKEN). The same call sent again while it runs is answered
at once with its job rather than queued behind itself. A job whose session ends,
whether stopped or found gone, is lost with the progress and outputs it had
written. Silence alone never ends a job, since a long cook can hold Houdini's
interpreter and a machine can sleep; a job still going says how long its
session has said nothing in silent_s. A job whose caller was handed the
job to follow, rather than its answer, leaves a readable copy of itself
beside the scene file when it ends, in .agent/jobs/, and nothing is made
beside a scene file that is not there. Jobs are kept for 7 days after they
end, and their copies go with them.
hou_node_type reads a node type from the running Houdini before any node of
it is made, so installed assets and this build's own versions are the ones
described. Name a type and a context (obj, sop, lop, cop, dop,
top, chop, out, mat or vop, or a category name such as Sop) for
its inputs and outputs with their labels and its real parameter names with
their defaults; full adds menus, ranges, folders, hidden parameters and the
first line of its help. A bare name is the version Houdini would make for it,
and resolved_from says which name was asked for. A multiparm carries its
instance template under instances, and a menu that a script fills in says
dynamic rather than running the script. query searches every type by
keyword: the exact name first, then a name that starts with it, one that
holds it, the label and the help line. Hidden types come only with include
hidden. A name that is not there is TYPE_NOT_FOUND with up to five near
names, or with the contexts that have it. Parameters and search rows page the
way hou_inspect does, and a page after any row or the asset's library
changed says changed. A page token is state the caller holds and can edit,
as with the other tools, not proof that this tool wrote it: it is checked for
shape and tied to its session and arguments, and no more.
Everything comes from the node type itself: nothing is cooked or made. An
asset's input labels come from its dialog script, and a type built into
Houdini takes them from the headings of its help page, which only
approximate what the node shows; labels_from says which it was. A ramp
says how many points it starts with in default_points. Help is the
asset's own embedded help, or a page from the nodes.zip Houdini ships or
from any help/nodes folder on Houdini's search path, which is where
packages keep theirs. A search with no context leaves out data recipes,
managers and the networks that only hold other contexts, and equally close
matches come in context order, SOPs first. Search ranks by the shipped and
package help pages only: an asset's embedded help is read for one card, or
for the rows a page returns, and never changes the order. A query is at most
200 characters and 16 different words, and a search asked to stop hands back
what it found with stopped. When the shipped help is missing or cannot be
read, the answer says help_available: false, and it is tried again a
minute later.
hou_docs reads Houdini's own documentation for the build in use. search
looks for query in page titles and first paragraphs and ranks a title that
is the query first, then one that starts with it, then one that holds it,
then a page whose first paragraph holds every word. For nodes, VEX functions
and Python classes the internal name, such as attribwrangle, counts as a
title. Among equals the current version of a page comes before older and
deprecated ones, and release notes come last. page reads one page by its
help path, such as nodes/sop/attribwrangle, or by a node type with its
namespace or version, such as nodes/sop/copytopoints::2.0; vex reads one
VEX function's page by its name. A page says its version when it has one.
Text comes plain, or markdown with its headings, lists and code kept;
table rows stay on one line. Past max_chars (20,000 unless you say) the
text is cut, says truncated, and the whole page goes to the spill folder,
named in spill_path.
Pages come from the install's own houdini/help folder first, which answers
in a few milliseconds and needs nothing from the session. With a session, that
is only a folder of exactly the session's build; with no session, the install
hython or houdini_build names in config.toml, or the newest on this
machine. The session's help server is read only for a build with no folder
here, or for a page the folder does not have. It runs inside the session, so
it is slow and stops answering while the session cooks or runs code: asking
where it is never waits behind other work, each request has two seconds from
start to finish, redirects are refused, and a help server that timed out is
left alone for a minute. source says which answered and build which build
was read; HELP_UNAVAILABLE says neither could. Pages from both are kept in
a small cache under the state folder, and search uses an index of every page,
built once per build and kept beside it; the first result's note says how
long that took. The cache holds at most 64 MB across builds, the builds used
least recently going first, and a build whose install has gone is removed.
hou_compare puts a candidate image beside a reference and says how far
apart they are, as pictures and as numbers. It never says pass or fail: there
is no match flag and no built in threshold. The candidate is one of four
sources:
file: an image given by its absolute path; a path with nothing there isFILE_NOT_FOUND.viewportornode: a picture captured for the compare, through the same code ashou_captureand with the capture arguments the candidate carries (path,camera,frame_target,display,resolution,frame,region, andtimeout_sfor how long to wait for it). It is a capture like any other, under an operation id made from the compare's own with:captureadded: a run in thecapturefolder and a jobhou_jobscan read. The reference and anymaskare read first, so one that will not do costs no capture. When the reference was registered with a camera, that camera frames the capture unless the candidate names its own camera, and the capture is made at the reference's aspect (its own size, or 2048 pixels on the long edge when larger) unless the candidate names its own size, which then sets the size alone. A camera the reference names that is gone or is not a camera is the reference's error (argument: reference, with the camera in the details). A capture whose aspect is off the reference's by more than a pixel says so inwarnings, beside the capture's own warnings. A capture that runs past its time answersTIMEOUTwith itsjob_id: wait for it withhou_jobs, then compare with arendercandidate of that job.render: the newest image on disk that a finished job wrote (job_id), or that runs of a node wrote (path, the node), read from the run records. A capture job's image is the one its answer names; any other job's is the newest image among the runs it took, such as a path ahou_pythonjob took withmcp.output_path. A job not yet ended isJOB_RUNNING, with a hint to wait on it withhou_jobs; a job or node with no image on disk isNO_OUTPUT.
The result's sources.candidate says which: for a capture its run_id,
job_id, path, route, frame, the camera that framed it and
framed_by (reference, candidate or capture), and unsaved_hip when
the scene had no file; for a render the run, the job and the node.
result.json keeps the same, with the path written relative to itself. The work runs in the server with NumPy and Pillow, in a fixed order,
and the result records every step:
Colour. A PNG, JPEG or TIFF is converted to sRGB through its embedded ICC profile; with none, sRGB is assumed and the result says
assumed_srgb. The full scale of a sample comes from the file's bit depth, never from its pixels: 16 bit greys and 16 bit RGB or RGBA PNGs are read at 16 bits, and the colour record says how many bits the file had and how many were read. An EXR or HDR file is read by the session, with OpenImageIO when the session has it and otherwise through a COPfilenode made and removed for the purpose (which a session with a user interface counts as a change to the scene, and the result says so). Its colour is divided by alpha and brought to display values with the session's OpenColorIO display and view. The result names the configuration by file name and content hash, the display, view, exposure, channels and what was done with alpha.transfer_mismatch_possibleis set when a scene linear side went through a view that is not a plain sRGB display, when two scene linear sides went through different views, or when the mean luminance of the two sides is more than 25 percent apart after alignment.Alignment at native size:
align(fitletterboxes to the reference's aspect,fillcrops to cover it,stretch,nonekeeps the candidate's pixels one for one), thenadjust(dxanddyfrom -1 to 1, in shares of the frame, andscalefrom 0.05), thenauto_shift, a translation found by phase correlation and applied only when it lowers the mean difference where the candidate covers; otherwiseshift_pxsaysapplied: falsewith the reason, the estimate and both errors. A candidate with more pixels than the reference keeps them: the grid grows instead, up to four times. Only the part of an enlarged candidate that lands on the frame is ever made. There is no fixed largestscale: one that would place the candidate over four times the frame's area is refused, which for a candidate that already fills the frame is a little over 2.Crops before any shrinking. Named regions and
region, each[x0, y0, x1, y1]in shares of the frame, are cut from the aligned native images, and a detail crop's numbers are counted at that size, a block of rows at a time.The overview: both sides shrunk to a long edge of 1024, or kept as they are when smaller, for the whole image numbers, the difference map and the sheet.
An image over 64 million pixels is shrunk by a whole factor as it is read, and
the result says resized_on_read. One past Pillow's own size limit is
IMAGE_TOO_LARGE.
The numbers are mean absolute error and RMSE per channel and overall, PSNR,
the share of the counted area whose difference is over tolerance (0.05
unless you say), and a rough box around the largest area of difference.
match_exposure adds the same numbers after one gain evens out the mean
luminance. In likeness mode, the default, they are labelled secondary,
because lighting and framing move them; in regression mode they are
primary. mask limits the counted area to the reference's registered mask or
alpha, or to a mask file; the candidate's alpha never decides it, and a
candidate whose alpha covers between 5 and 95 percent of it, with no mask,
gets a warning, because its transparent pixels count as the colour they
store. Numbers that cannot be
counted are named in missing with the reason, and the aligned pair and the
sheet are written all the same.
Every compare writes candidate.png and reference.png (the aligned pair),
diff.png, overview.jpg (candidate, reference and difference, labelled),
crops/<region>.png and result.json to a compare folder. result.json
names every file relative to itself and holds no place on the machine other
than the scene's own path. The call returns the overview as an image for the
person as well as the agent (return_image thumb, full or none). The
structured result, the text and the image of a reply stay within one
megabyte together: a full sheet that would not fit is sent as the thumbnail,
with image_downgraded.
set_reference copies an image into $HIP/.agent/reference/ and writes one
record beside it, <ref_id>.json, that is never written again: the content
hash, the colour profile or the assumption made (an EXR or HDR is read as far
as its header), and optionally the camera it was framed for, named regions
and a mask. The record names its image and mask relative to its own folder,
so a project that moves still finds them. A name may hold letters, digits,
underscore and dash; any other name is refused rather than changed.
Registering a name again makes a new record and id. list_references lists
the names, and hou_scene info at full detail lists them too, so a fresh
context finds the goal. A name is looked up before a path, and only an
absolute path is read as a file. The reference folder is fixed per scene: a
reference template whose folder part uses <name>, <run_id>, <date>,
<date_iso>, <time>, <ver> or <session> is refused when the
conventions are read. Compares with the same reference id and hash, camera,
crop, mask, colour records and settings form a series, and for a captured
candidate the same size, display, frame and region too; each run of a series
is one file under reference/series/<series_id>/, so two servers writing to
one shared folder never interleave. A result in a series with earlier runs
carries the trend, and one that starts a new series says which of those
changed.
hou_outputs hands out managed paths and reads back what a scene made.
resolve takes a place for a kind and a name, with ext when the kind's
own extension is not the one wanted and node for the node the output
belongs to, whose name is used when none is given. It answers with
parm_string, the line with $HIP that belongs on the node,
expanded_path, the absolute path for this run, and the version, folder,
run_id and sidecar beside them. A version is taken on every call; with
operation_id the same id sent again answers with the place it took. $JOB
and $HOUDINI_TEMP_DIR are read from the session, once, never from the
server's own environment. job and spill paths belong to the server and are
refused. list reads the runs this scene has made, newest first, with their
paths, kinds, versions, sessions, times and whether anything is on disk for
them; filter narrows it by kind, a name glob and since. lint reads
every output parameter under node (the whole scene unless named), walking
the network a node at a time and stopping when its page is full or the call is
asked to stop. An output is a file parameter a node type marks as written to,
a multiparm's instances included, or an unmarked one on a render node, as
Alembic and USD render nodes have; hooks, a renderer's logs, a render it reads
back and folders are not. One row per problem: absolute_path, outside_hip
(outside the folders the output table manages for this scene),
unversioned, missing_on_disk (for a sequence, at the current frame and at
both ends of the frame range, and with empty for a node's main output, or
one a toggle of its own turns on, that names nothing), frozen_after_run, expression for a value only an evaluation
could give and unexpanded for a variable it cannot fill in. Nothing is
evaluated and nothing cooks: values are expanded from the table's variables
and the node's and scene's own names. list and lint page with limit and
next_page, the way reads do.
hou_capture saves a picture of what a session shows: the viewport, one
node on its own, the network editor, a cop output or a pane by name.
Every file goes under the capture kind of the output table below, and the
answer carries the path, the width and height, the frame, the camera, the
route that made it and image_stats: the mean, least and most of each
channel at the image's own depth, flat for an image that is one value in
every channel, and non_empty, which is false for an empty file and, for the
viewport and a node, for an alpha that is zero everywhere. A flat image is a
picture, with a note. A capture whose every image is empty is
CAPTURE_EMPTY. A thumbnail of at most 512 pixels on its long edge comes back
as image content beside the text; return_image says thumb, full or
none.
The routes for the viewport, in order: the Scene Viewer that is showing,
flipbooked with settings of its own (no MPlay, the beauty pass only unless
guides); an existing Scene Viewer made the current tab for the capture and
then put back; and a flipbook render node made for the capture and taken
away after. A camera, display or frame_target asked for is applied for
the capture and the view is put back as it was, camera, pivot and width
included. The render node looks through a camera made for the capture:
one that follows a named camera and reads its lens by reference, so the named
camera is never written, or one fitted to the target's bounds from persp,
top, front, right or an {orbit, elevation}. A worker is started on
Qt's offscreen screen plugin, so it draws at one pixel to a point on any
display. In a session with a user interface that
route cannot know what the artist's view frames, so it says
framing_unverified; it is the only route a worker has. node draws that
node's object alone with the node carrying the display flag, and puts the
flag back. Whatever a capture makes for itself is made and taken away with
undo turned off. A route that fails part way takes its frames with it; a
capture stopped on request keeps the frames it wrote and lists them, and the
run record names each file, the capture's job and, for a node or cop
capture, the node, which is how hou_compare finds a node's newest picture.
The network editor and panes are made the
current tab and grabbed from their own window, which needs a user interface;
a worker answers UI_UNAVAILABLE. A render Houdini stops with an error is
CAPTURE_FAILED, with that error in the details. Every step that puts the
scene or the view back is tried, and one that fails makes a capture that
worked CLEANUP_FAILED, naming the step. Crops and the contact sheet are
made once per operation, so a reply sent again and a job read at the same
moment do not both write them. A viewport sequence goes a few frames at a
time, so it can be stopped between them, and its job row names each run and
the frames written so far.
views: quad captures persp, top, front and right, turntable4 four orbits
a quarter turn apart, and both add a two by two contact sheet, which is then
path. region crops each saved image to [x0, y0, x1, y1], fractions from
the top left. frames: [start, end, step] captures a sequence, which is a
job: it answers inline when it is done within inline_wait_s, and with the
job to follow in hou_jobs when it is not.
The Houdini side
nscr_houdini_mcp.bridge runs inside Houdini's own Python. It serves two
endpoints on loopback, registers the session in the coordination store and
takes it out again when the process quits.
What the bridge guards against: a web page in a browser on this machine, another person with an account on it, and the network. Not the owner's own programs. Anything running as the same person can read that person's files, token included, and could drive Houdini with or without this.
The token is never sent. Each request is signed with it and each answer is
signed back, so nothing on the port learns anything it could use again, and a
caller can tell the bridge from something that took its port after a crash. A
request is also refused if it carries Origin or Referer, names a host
other than this bridge, is not JSON, is over the size cap, or nests too deeply
for an envelope. The bridge registers no built in API route, so the one that
parses a posted form before any handler runs does not exist on its server.
Addressing a session, and sending a call twice
A session has two handles. session_id is random, minted at start and never
reused. alias is the readable name: a session with a scene is named after
the scene file, a worker is w1, w2 and so on, and two Houdinis on the same
file get different names. Either handle addresses a session. The name never
moves once it is settled, so a scene saved under a different name leaves the
session's name out of date, which health and every reply say and nothing
silently corrects.
scene_epoch counts the times a session has replaced its scene, which is any
open, new scene or reload. A call carrying an older epoch is refused with
SCENE_REPLACED and a summary of the scene there is now, before the tool
runs. A call addressed to a session whose process has gone is refused by the
calling end with SESSION_DEAD and the id of whatever answers to the same
name now. Nothing is sent to the new process.
A call that changes the scene carries an operation_id. The bridge takes a
receipt under that id before the work runs and finishes it with the answer, so
the same id arriving again is answered from the receipt rather than doing the
work twice. The same id with different arguments is OPERATION_MISMATCH, and
an id whose first attempt left no answer is OUTCOME_UNKNOWN rather than a
guess. Reads take no receipt.
That is what makes a retry safe, and the only retry the client does by itself: one, on a lost reply (the connection closed, or the read ran out of time), and only for a call that carries an operation id, using that same id. A reply that arrived is never sent again, whatever it says.
A reply is usually lost because this end gave up on the socket first, so the
work is often still running when the second send arrives. That send queues
behind it, asking for a longer turn than the first, and can still come back
SESSION_BUSY. The answer is in the receipt as soon as the work ends, so the
way on from there is the same id again, not a fresh call.
Start one in a headless Houdini:
hython -m nscr_houdini_mcp.bridge.main --home <state folder>It stops when its input closes, or on the word stop. A start like this puts
Qt on its offscreen screen plugin unless QT_QPA_PLATFORM already names one,
as the pool does for its workers. On another plugin and a dense display a
render node draws larger than asked; a capture then reads the scale and makes
up for it, or says framing_unverified when it cannot.
Pacing a Houdini with a user interface
In a session with a user interface every call runs on Houdini's main thread,
the one that draws the interface. Calls sent back to back leave it no room,
and an agent in a loop can send a great many, so the server paces the calls it
sends to such a session: one call out at a time, a pause after each one ends,
and a cap on how many start in a second. Two keys in config.toml set the pace:
gui_min_pause_ms = 50 # least time from one call ending to the next starting
gui_max_calls_per_s = 10 # most calls that may start in any one secondA value of 0 turns that rule off, and both at 0 turn pacing off altogether; a negative value is refused.
The wait for a turn comes out of the call's own wait_s (a second unless it
says), and the bridge gets what is left. Calls sent side by side queue and go
out one after another, however long the call out may run. A call is answered
at once with SESSION_BUSY when the pause and the cap alone would hold it
past its wait, when it passed skip_if_busy, or when the queue is full; one
whose wait runs out in the queue gets the same answer then. So a call may be
refused up front when the calls queued ahead of it, at the rate cap, already
fill its wait_s, and retry_after_s says when to come back. Nothing of a
refused call is sent, so a caller that has given up never has its change made
later. retry_after_s is an estimate from the calls queued ahead and how
long the session's last few calls took, between the pause and 30 seconds; a
call out longer than usual is guessed to run as long again. The answer also
carries queued_ahead, how many calls waited ahead. A call that did wait
says how long in its trace, as throttled_ms, and when it was let through,
as admitted_at.
The pace is kept per server process and per session. Two agents sharing one
server process share its one allowance; two server processes, one per
client, each have their own, so together they get no more than twice it.
A call sent again under the operation id of the call that is out goes
straight through, since the bridge answers it from that call's receipt. At
most 32 calls wait on one session's turn; one more is answered
SESSION_BUSY at once, and a call whose client cancels leaves the queue
without being sent. Workers are headless and are not paced. A cancel is never
paced either, since it runs beside the call it stops.
The bridge has a limit of its own, whoever sends: it remembers the signed
requests of the last two minutes, 20,000 at most, and refuses one more with
FLOOD_GUARD, naming the limit and how many seconds to wait.
Installing the Houdini side
One command writes the Houdini package that puts this on a session's path:
nscr-houdini-mcp bridge install # for Houdini 22.0
nscr-houdini-mcp bridge install --houdini-version 22.0 --dry-run
nscr-houdini-mcp bridge install --autostart # every Houdini opens a port
nscr-houdini-mcp bridge uninstall
nscr-houdini-mcp bridge status
nscr-houdini-mcp bridge snippetIt writes one file, nscr_houdini_mcp.json, into the packages folder Houdini
reads. That folder is often not where a shell would guess, because a launcher,
a line in houdini.env, another package or a synced documents folder can move
it, so it is worked out in this order:
--packages-dir, oninstall,uninstallandstatus.HOUDINI_PACKAGE_DIRin this shell. Houdini reads every folder it names, so the first is written into and the rest are reported.A real Houdini, asked. One short script runs in
hythonwith a ten second cap and reports the home folder,HOUDINI_PACKAGE_DIR,HOUDINI_USER_PREF_DIRandHSITEas Houdini itself sees them, afterhoudini.envand packages. A Houdini that is missing or slow leaves a note and the lookup carries on.HSITEis other people's, so it is reported and never written to.HOUDINI_USER_PREF_DIRin this shell, with__HVER__filled in.The usual folder for the system:
~/Library/Preferences/houdini/<version>on macOS,Documents\houdini<version>under the profile on Windows,~/houdini<version>on Linux.
Nothing is remembered between runs, and bridge status prints which of those
decided and every folder it considered.
The file points HOUDINI_PATH at this project's houdini/ folder and
PYTHONPATH at its src/, both worked out from where this copy is running,
so there is nothing to edit by hand. Every file it writes carries a marker: a
package of the same name that this did not write, or a link where the file
should be, is reported and left exactly as it is, and uninstall takes away
only its own, plus any folder it had to make and nothing else.
Installing opens no port. Auto start is off unless you ask for it with
--autostart, which sets NSCR_MCP_AUTOSTART to 1 in the package. The
start hangs off python3.13libs/ready.py, which Houdini runs for every folder
on its path, in a session with an interface and without, so this package takes
nobody else's startup script away.
bridge status lists the sessions running now with their ids, names, ports,
scenes and whether each is busy, asks each one whether it is healthy under one
shared time budget, and says which folders have the package and where Houdini
is installed.
To start a bridge inside a Houdini that is already open, paste what bridge snippet prints into its Python shell. It works out the source path as it
prints, so the lines run as they stand.
houdini/packages/nscr_houdini_mcp.json is the same file as a template, for
anyone who would rather place it themselves.
The worker pool
A worker is a headless Houdini of its own with a bridge in it, kept warm so the next piece of work does not pay a cold start:
nscr-houdini-mcp bridge worker start # one worker, if there is room
nscr-houdini-mcp bridge worker start --weight heavy --max-threads 8
nscr-houdini-mcp bridge worker list
nscr-houdini-mcp bridge worker reserve w1 --job job-1
nscr-houdini-mcp bridge worker release w1
nscr-houdini-mcp bridge worker stop w1A worker started from a client through hou_sessions takes pool_cap,
hython or houdini_build, and worker_ports from config.toml, and so
does worker start for any flag it is not given.
Three workers may run at once by default. The slot is taken in one
transaction in the coordination store, which counts the workers that are
still starting, so several servers on one machine cannot hand out the same
last slot, and POOL_FULL is the answer when there is none. A start that
fails gives its slot straight back. Each reservation also carries a weight,
and the pool holds a budget of its own, so a heavy job is refused while the
machine is busy even when a slot is free.
A worker does not belong to the process that started it. It is started
detached, with its output in logs/worker-<name>.log under the state folder,
and it stays when its server exits, so a client restart does not throw the
warm pool away. The log is private to its owner and is rolled over when it
grows, keeping the last two. What ends a worker is its lease: it watches its
own row and goes when a server asks it to, or when nobody has wanted it for
half an hour. Routing a call to it renews the lease, and a worker running a
call or a job is never idle, however long the work takes and whether or not
anybody routes to it meanwhile. stop asks first and
ends the process itself only if the ask was not enough, and never unless that
process can be shown to still be the worker. start exits 3 when the pool is
full and 1 when the start went wrong.
A worker inherits this process's environment on purpose: it has to see the
same licensing, path and package settings as the shell the tool was started
from, or it is a different Houdini from the artist's. Three things are decided
rather than inherited. Its state folder is the one the pool is using, its
reservation token arrives in the environment and never on the command line,
which every account on the machine can read, and its thread cap is either the
one you named with --max-threads or the one the weight implies: a heavy
worker gets the machine, a light one is left at Houdini's own default.
The Windows side of this, the detached start, the kill and the start stamp, is written and read but has not been run on Windows yet.
Each worker is asked once, when it comes up, what it can do: the build, the license it got, the renderers that are really installed, how it can make a picture, and that it can be cancelled. The answer is kept beside the worker, so another process can pick one without asking it anything.
Tests marked houdini need a Houdini on the machine and skip when there is
none, so pytest -q is complete everywhere. Run only those with pytest -m houdini, or skip them with pytest -m "not houdini". The bridge is found
through NSCR_MCP_HYTHON, then HFS, then the usual install folder.
Running the sequence yourself
tests/sequence.py is one scripted pass over every tool, the way a client
would go about a first piece of work: list the tools, find a session, start a
worker, read its scene, build three nodes with a lost reply sent again under
the same operation id, read them back, look up attribwrangle and its help
page, take a cache path, save the next version, follow slow code that became a
job, compare two images and stop the worker. Every step checks the shape of
its result and records the tool, how long it took, and whether the reply
carried structured content, a text block and an image.
pytest -m houdini -s tests/test_sequence_hython.pyruns it three times, each with a server process of its own over stdio: under
the current protocol revision, under the older handshake revision, and through
the SDK's lower level client class. -s prints each run's steps. A last check
holds the three to the same shapes. The only difference the revision makes is
its own: the current one stamps every result with the server's details in its
metadata. Everything the pass writes stays in pytest's temporary folder.
tests/test_storm_cap_hython.py has two server processes hammer one worker
that is paced as if it had an interface, which only a test can ask for, through
the server's constructor, and prints the rates with and without the pace. Both
files carry their own deadline of 900 seconds, longer than the suite's usual
limit, for a slow Houdini to start in.
Output folders
No tool takes an output path. You name a kind and a name, and the server
builds the path from a token table. Renders, flipbooks and comps go to dated
folders, because a person browses them by the day they were made. Caches, USD
layers and hip files keep a stable folder under the name, because the scene
reads them back and only the version should change between runs. Files the
agent writes for itself go under $HIP/.agent/.
The defaults, with <ver> three digits and <date> as YYYYMMDD:
render $HIP/renders/<date>_<name>/v<ver>/<name>_v<ver>.$F4.exr
flipbook $HIP/flipbook/<date>_<name>/v<ver>/<name>_v<ver>.$F4.png
comp $HIP/comp/<date>_<name>/v<ver>/<name>_v<ver>.$F4.exr
cache $HIP/geo/<name>/v<ver>/<name>_v<ver>.$F4.bgeo.sc
usd $HIP/usd/<name>/v<ver>/<name>_v<ver>.usd
hip $HIP/<name>_v<ver>.hip
capture $HIP/.agent/captures/<date>/<time>_<name>_<run_id>.png
compare $HIP/.agent/compare/<date>_<name>/<ver>_<run_id>/
reference $HIP/.agent/reference/<name>_<run_id>.png
check $HIP/.agent/checks/<date>/<time>_<name>_<run_id>.png
job $HIP/.agent/jobs/<job_id>.json
reference $HIP/.agent/reference/<name>_<run_id>.png
spill <spill folder>/<YYYY-MM-DD>/<time>-<name>-<run_id>.jsonA reference is an image an agent keeps to check its work against, and a
check is what it made to compare with one. A job path is only ever written
by a session or the server, for the record of a job that has ended. A spill
goes to the server's own spill folder, where results too large to return go,
whatever the scene is: its line starts at <spill_root> and holds no Houdini
variable, it is never handed to code and it never goes on a node. The row is
kept for the server's own use; the spill writer does not read it yet. Neither
is ever handed out, by mcp.output_path or by hou_outputs resolve.
Each output comes with two lines. The template keeps its Houdini variables and
uses ${OS} for a name that came from the node, so it reads well and a scene
carries it to another machine:
$HIP/renders/20260921_${OS}/v003/${OS}_v003.$F4.exr. That is the line to show
a person and to leave on a node with no run yet. What the server writes on the
node for a run it has started is the expanded path, because the run is the
server's from then on: renaming the node mid render must not send half the
frames somewhere else, and the record and the scene have to say the same thing.
A rename still carries through to the next run, which takes its own version.
When the run is over, whether it ended done, failed or cancelled, the node
gets back what it held before, the $HIP line a person set or the expression
that fed it, so a scene saved afterwards never carries a path from this
machine. A save while the run is going writes that same value and puts the
run's path back straight after. Every parameter a run freezes is recorded in
the coordination store before it is touched, with what it held, and the
record goes once that is back. Each record carries a token: a parameter held
by one run is refused to another with PARM_FROZEN, naming the run, and only
the holder gives it back. The call that froze it gives it back when it ends,
finding a node renamed in the meantime by its session id. A session that dies
with a parameter frozen cannot, so the next session to load that scene with
hou_scene open, or to save it with save or save_increment, does; its
copy may be older than the file, so the record stays while the file on disk
still holds the path. A record from a scene that was never saved is dropped,
because no session can open it again. A parameter someone changed in between
keeps their value. lint reports anything left over as frozen_after_run.
$HIP, $HOUDINI_TEMP_DIR and $JOB are the variables this fills in, the
first two from the session and $JOB from the environment. A table naming any
other variable is refused when it is read, so no run ever creates a folder
called $SHOT. Roots have to start at one of those three, and templates,
roots, producer levels and extensions may not step out of a folder with ..;
the finished path is checked against the root once more before anything is
created.
A version number is taken inside one store transaction and the version folder
is then created with an exclusive mkdir, so a number is used once even with
several processes and several machines on the same scene folder. A hip file has
no folder of its own, so it claims a small .claim file beside it instead,
which is what stops two machines with their own stores from writing the same
v001. Every file the agent writes for itself carries the run id, so two
captures in the same second are two files. Each run leaves a readable
_run.json beside its output with the scene, session, node, version and paths,
absolute for the machine that made the run and again relative to the root, so
the same folder read from somewhere else still makes sense.
Edit the table where it suits you. Built in defaults come first, then
config.toml in the state folder (NSCR_MCP_HOME moves it), then
.agent/outputs.toml beside the scene, which may set only [outputs] and
[conventions]. Later wins key by key, and a file that cannot be used says
which key and why.
[outputs]
producer = "3d/hip" # an extra level under each kind, off by default
cache_root = "$JOB/cache" # heavy caches on a fast local disk
version_width = 3
[outputs.grammar]
comp = "<output_root>/comp/<name>/v<ver>/<name>_v<ver>.<frame>.<ext>"
[outputs.extensions]
flipbook = "jpg"
[conventions]
output_marker_type = "null"
output_marker_prefix = "OUT_"A scene that has never been saved has no $HIP, so its runs go under
$HOUDINI_TEMP_DIR/nscr-houdini-mcp/<session>/ and every result says
unsaved_hip, which is the cue to save and run again. The template keeps the
variable, so saving that scene later leaves nothing about this machine in it.
Skills
A skill is a plain SKILL.md file that tells an agent how to work: what to
plan before building, what to look at before calling something finished, and
when to stop and report. The first one, houdini-artist, covers modelling,
lookdev, cleaning up a network, matching a reference, finding out why a scene
is slow and getting a scene ready to hand on. It works best with this server
connected, and still helps with planning when it is not.
The skills ship with the package:
nscr-houdini-mcp skills path # where the shipped skills are
nscr-houdini-mcp skills install <folder> # copy them into a folder you nameClients that read skills usually look in a skills folder in your user settings
or in the project, with one subfolder per skill. Point install at the one
yours uses. A copy already there that differs from the shipped one is left
alone, so your edits survive an upgrade; --force writes the shipped files
over it.
The skills are yours to edit. The block at the top, House conventions, is the
dial: the output marker, when to save an increment, how many attempts one
detail gets, what good enough means for this pass, what node colours mean and
where files may be written. Change those lines to fit how you work, and keep
the output marker in step with the [conventions] table above.
License
MIT
Maintenance
Related MCP Connectors
Hosted MCP server for task-first delegation to remote workstations and workers.
Cloud Blender for AI agents: scenes, assets, renders, MP4, STL, GLB — over hosted remote MCP.
Focused MCP server for OpenAI image/audio generation (v2.0.0). Wraps endpoints via HAPI CLI.
Remote MCP server exposing 330 production AI-agent services for web/data processing, validation, AI utilities, blockchain/crypto utilities, and x402 pay-per-use access.
Related MCP Servers
- AlicenseNot gradedqualityDmaintenanceEnables natural language control of SideFX Houdini for tasks including node management, parameter editing, and geometry inspection. It leverages RPYC to execute Python scripts and manage scene data through an MCP-compatible interface.MIT
- AlicenseNot gradedqualityBmaintenanceAn MCP server for controlling SideFX Houdini via hrpyc, enabling AI assistants to interact with Houdini sessions.74MIT
- AlicenseCqualityBmaintenanceConnects SideFX Houdini to Claude via the Model Context Protocol, enabling control of Houdini scenes, nodes, rendering, and more through 166 MCP tools.1004MIT
- AlicenseNot gradedqualityBmaintenanceAn MCP server for SideFX Houdini that enables AI assistants to interactively control, construct, inspect, and visually verify 3D procedural scenes through tools like node creation, parameter editing, viewport capture, and rendering.MIT